diff --git a/server/cmd/kol-assist-worker/main.go b/server/cmd/kol-assist-worker/main.go index 969e0b5..448a3a9 100644 --- a/server/cmd/kol-assist-worker/main.go +++ b/server/cmd/kol-assist-worker/main.go @@ -26,6 +26,8 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + app.StartConfigWatcher(ctx) + assistworker.NewKolAssistWorker( app.DB, app.RabbitMQ, @@ -33,9 +35,9 @@ func main() { app.LLM, app.Logger, app.Cache, - app.Config.Generation.WorkerConcurrency, - app.Config.Generation.ArticleTimeout, - ).Start(ctx) + app.Config().Generation.WorkerConcurrency, + app.Config().Generation.ArticleTimeout, + ).WithConfigProvider(app.ConfigStore).Start(ctx) app.Logger.Info("kol-assist-worker started") diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index 290dcff..a704669 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -30,10 +30,12 @@ func main() { configPath = p } - cfg, err := opsconfig.Load(configPath) + configStore, err := opsconfig.NewStore(configPath) if err != nil { log.Fatalf("ops-api load config: %v", err) } + defer configStore.Close() + cfg := configStore.Current() logger, err := observability.NewLogger(cfg.Log.Level, cfg.Log.Format) if err != nil { @@ -92,6 +94,23 @@ func main() { logger.Sugar().Fatalf("ops-api seed default admin: %v", err) } + watcherCtx, watcherCancel := context.WithCancel(context.Background()) + defer watcherCancel() + go func() { + if err := configStore.Watch(watcherCtx, logger, func(event opsconfig.ReloadEvent) { + if event.Current == nil { + return + } + issuer.Update(event.Current.JWT.Secret, event.Current.JWT.AccessTTL) + adminUserSvc.UpdateDefaultPlanCode(event.Current.AdminUsers.DefaultPlanCode) + if opsconfig.RestartRequired(event.Changes) { + logger.Warn("ops config hot reload changed fields that require process restart") + } + }); err != nil { + logger.Warn("ops config hot reload watcher stopped", zap.Error(err)) + } + }() + if cfg.Server.Mode == "release" { gin.SetMode(gin.ReleaseMode) } @@ -130,6 +149,7 @@ func main() { <-quit logger.Info("ops-api shutting down...") + watcherCancel() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { diff --git a/server/cmd/scheduler/main.go b/server/cmd/scheduler/main.go index 19d0c37..12fb6c7 100644 --- a/server/cmd/scheduler/main.go +++ b/server/cmd/scheduler/main.go @@ -36,7 +36,8 @@ func main() { } defer app.Close() - generationCfg := app.Config.Generation + generationProvider := tenantapp.NewGenerationStreamDisabledConfigProvider(app.ConfigStore) + generationCfg := app.Config().Generation generationCfg.StreamEnabled = false knowledgeSvc := tenantapp.NewKnowledgeService( @@ -46,11 +47,11 @@ func main() { app.ObjectStorage, app.LLM, app.Logger, - app.Config.Retrieval, - app.Config.LLM, + app.Config().Retrieval, + app.Config().LLM, 0, 0, - ) + ).WithConfigProvider(app.ConfigStore) promptRuleSvc := tenantapp.NewPromptRuleGenerationService( app.DB, @@ -59,8 +60,8 @@ func main() { knowledgeSvc, app.GenerationStreams, generationCfg, - app.Config.LLM.MaxOutputTokens, - ).WithCache(app.Cache).WithLogger(app.Logger) + app.Config().LLM.MaxOutputTokens, + ).WithCache(app.Cache).WithLogger(app.Logger).WithConfigProvider(generationProvider) monitoringCallbackService := tenantapp.NewMonitoringCallbackService( app.DB, @@ -69,20 +70,22 @@ func main() { app.LLM, app.Logger, ) - monitoringService := tenantapp.NewMonitoringService( - app.DB, - app.MonitoringDB, - app.RabbitMQ, - app.Config.MonitoringDispatch, - app.Config.BrandLibrary, - app.Logger, - ).WithRedis(app.Redis) + monitoringService := app.MonitoringService ctx, cancel := context.WithCancel(context.Background()) defer cancel() + app.StartConfigWatcher(ctx) + var workerWG sync.WaitGroup - scheduleDispatchWorker := internalscheduler.NewScheduleDispatchWorker(app.DB, app.RabbitMQ, promptRuleSvc, app.Cache, app.Logger, app.Config.Scheduler) + scheduleDispatchWorker := internalscheduler.NewScheduleDispatchWorker( + app.DB, + app.RabbitMQ, + promptRuleSvc, + app.Cache, + app.Logger, + app.Config().Scheduler, + ).WithConfigProvider(app.ConfigStore) monitoringDailyTaskWorker := tenantapp.NewMonitoringDailyTaskWorker(monitoringService, monitoringCallbackService, app.Logger) monitoringResultRecoveryWorker := internalscheduler.NewMonitoringResultRecoveryWorker(app.MonitoringDB, monitoringCallbackService, app.Logger) monitoringLeaseRecoveryWorker := internalscheduler.NewMonitoringLeaseRecoveryWorker(app.MonitoringDB, app.Logger) @@ -97,10 +100,10 @@ func main() { startSchedulerWorker(ctx, &workerWG, "knowledge_deleted_cleanup", app.Logger.Sugar(), knowledgeDeletedCleanupWorker.Run) var metricsServer *http.Server - if app.Config.Scheduler.HTTPPort > 0 { + if app.Config().Scheduler.HTTPPort > 0 { metricsServer = startSchedulerMetricsServer(app) } else { - app.Logger.Sugar().Infof("scheduler metrics http server disabled by http_port=%d", app.Config.Scheduler.HTTPPort) + app.Logger.Sugar().Infof("scheduler metrics http server disabled by http_port=%d", app.Config().Scheduler.HTTPPort) } app.Logger.Info("scheduler started") @@ -163,13 +166,13 @@ func waitSchedulerWorkers(wg *sync.WaitGroup, timeout time.Duration, logger inte } func startSchedulerMetricsServer(app *bootstrap.App) *http.Server { - token := strings.TrimSpace(app.Config.Scheduler.InternalMetricsToken) + token := strings.TrimSpace(app.Config().Scheduler.InternalMetricsToken) if token == "" { app.Logger.Warn("scheduler metrics http server not started: scheduler.internal_metrics_token is required") return nil } - authMiddleware := schedulerMetricsAuthMiddleware(token) + authMiddleware := schedulerMetricsAuthMiddleware(app) app.Engine.GET("/api/internal/metrics/monitoring/daily-tasks", authMiddleware, func(c *gin.Context) { response.Success(c, tenantapp.MonitoringDailyTaskMetricsSnapshotValue()) }) @@ -178,7 +181,7 @@ func startSchedulerMetricsServer(app *bootstrap.App) *http.Server { prometheusHandler.ServeHTTP(c.Writer, c.Request) }) - addr := schedulerHTTPAddr(app.Config.Scheduler.HTTPHost, app.Config.Scheduler.HTTPPort) + addr := schedulerHTTPAddr(app.Config().Scheduler.HTTPHost, app.Config().Scheduler.HTTPPort) server := &http.Server{ Addr: addr, Handler: app.Engine, @@ -219,9 +222,21 @@ func schedulerHTTPAddr(host string, port int) string { return net.JoinHostPort(host, strconv.Itoa(port)) } -func schedulerMetricsAuthMiddleware(token string) gin.HandlerFunc { - expected := strings.TrimSpace(token) +func schedulerMetricsAuthMiddleware(app *bootstrap.App) gin.HandlerFunc { + return schedulerMetricsAuthMiddlewareWithToken(func() string { + if cfg := app.Config(); cfg != nil { + return cfg.Scheduler.InternalMetricsToken + } + return "" + }) +} + +func schedulerMetricsAuthMiddlewareWithToken(token func() string) gin.HandlerFunc { return func(c *gin.Context) { + expected := "" + if token != nil { + expected = strings.TrimSpace(token()) + } if expected == "" || !schedulerMetricsTokenMatches(c, expected) { response.Error(c, response.ErrUnauthorized(40181, "internal_metrics_unauthorized", "valid internal metrics token is required")) c.Abort() diff --git a/server/cmd/scheduler/main_test.go b/server/cmd/scheduler/main_test.go index 71ffc43..9133a9f 100644 --- a/server/cmd/scheduler/main_test.go +++ b/server/cmd/scheduler/main_test.go @@ -38,7 +38,7 @@ func TestSchedulerMetricsAuthMiddleware(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { router := gin.New() - router.GET("/metrics", schedulerMetricsAuthMiddleware("secret-token"), func(c *gin.Context) { + router.GET("/metrics", schedulerMetricsAuthMiddlewareWithToken(func() string { return "secret-token" }), func(c *gin.Context) { c.Status(http.StatusNoContent) }) diff --git a/server/cmd/tenant-api/main.go b/server/cmd/tenant-api/main.go index 76902d9..bdc6ab0 100644 --- a/server/cmd/tenant-api/main.go +++ b/server/cmd/tenant-api/main.go @@ -28,22 +28,24 @@ func main() { workerCtx, workerCancel := context.WithCancel(context.Background()) defer workerCancel() + app.StartConfigWatcher(workerCtx) + app.GenerationStreams.Run(workerCtx) app.DesktopTaskStreams.Run(workerCtx) app.DesktopDispatch.Run(workerCtx) - monitoringService := tenantapp.NewMonitoringService(app.DB, app.MonitoringDB, app.RabbitMQ, app.Config.MonitoringDispatch, app.Config.BrandLibrary, app.Logger).WithRedis(app.Redis) + monitoringService := app.MonitoringService monitoringCallbackService := tenantapp.NewMonitoringCallbackService(app.DB, app.MonitoringDB, app.RabbitMQ, app.LLM, app.Logger) tenantapp.NewMonitoringCollectOutboxWorker(monitoringService, app.Logger).Start(workerCtx) - tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx) - tenantapp.NewMonitoringProjectionRebuildWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config.MonitoringWorkers).Start(workerCtx) + tenantapp.NewMonitoringResultIngestWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config().MonitoringWorkers).Start(workerCtx) + tenantapp.NewMonitoringProjectionRebuildWorker(monitoringCallbackService, app.RabbitMQ, app.Logger, app.Config().MonitoringWorkers).Start(workerCtx) tenantapp.NewDesktopAccountHealthSinkWorker(app.DB, app.RabbitMQ, app.Logger).Start(workerCtx) imageService := tenantapp.NewImageService(app.DB, app.ObjectStorage, app.RabbitMQ, app.Logger) tenantapp.NewImageAssetWorker(imageService, app.RabbitMQ, app.Logger).Start(workerCtx) transport.RegisterRoutes(app) - addr := fmt.Sprintf(":%d", app.Config.Server.Port) + addr := fmt.Sprintf(":%d", app.Config().Server.Port) app.Logger.Sugar().Infof("tenant-api starting on %s", addr) go func() { diff --git a/server/cmd/worker-generate/main.go b/server/cmd/worker-generate/main.go index bc17beb..5d94092 100644 --- a/server/cmd/worker-generate/main.go +++ b/server/cmd/worker-generate/main.go @@ -25,7 +25,8 @@ func main() { } defer app.Close() - generationCfg := app.Config.Generation + generationProvider := tenantapp.NewGenerationStreamDisabledConfigProvider(app.ConfigStore) + generationCfg := app.Config().Generation generationCfg.StreamEnabled = false knowledgeSvc := tenantapp.NewKnowledgeService( @@ -35,11 +36,11 @@ func main() { app.ObjectStorage, app.LLM, app.Logger, - app.Config.Retrieval, - app.Config.LLM, + app.Config().Retrieval, + app.Config().LLM, 0, 0, - ) + ).WithConfigProvider(app.ConfigStore) templateSvc := tenantapp.NewTemplateService( app.DB, @@ -52,8 +53,8 @@ func main() { knowledgeSvc, app.GenerationStreams, generationCfg, - app.Config.LLM.MaxOutputTokens, - ).WithCache(app.Cache) + app.Config().LLM.MaxOutputTokens, + ).WithCache(app.Cache).WithConfigProvider(generationProvider) promptRuleSvc := tenantapp.NewPromptRuleGenerationService( app.DB, @@ -62,8 +63,8 @@ func main() { knowledgeSvc, app.GenerationStreams, generationCfg, - app.Config.LLM.MaxOutputTokens, - ).WithCache(app.Cache).WithLogger(app.Logger) + app.Config().LLM.MaxOutputTokens, + ).WithCache(app.Cache).WithLogger(app.Logger).WithConfigProvider(generationProvider) publishJobSvc := tenantapp.NewPublishJobService( app.DB, @@ -80,8 +81,8 @@ func main() { knowledgeSvc, app.GenerationStreams, generationCfg, - app.Config.LLM.MaxOutputTokens, - ).WithCache(app.Cache) + app.Config().LLM.MaxOutputTokens, + ).WithCache(app.Cache).WithConfigProvider(generationProvider) kolWorker := generateworker.NewKolGenerationWorker( app.DB, @@ -89,12 +90,14 @@ func main() { knowledgeSvc, app.Logger, generationCfg, - app.Config.LLM.MaxOutputTokens, - ).WithCache(app.Cache) + app.Config().LLM.MaxOutputTokens, + ).WithCache(app.Cache).WithConfigProvider(app.ConfigStore) ctx, cancel := context.WithCancel(context.Background()) defer cancel() + app.StartConfigWatcher(ctx) + generateworker.NewArticleGenerationWorker( app.DB, app.RabbitMQ, @@ -103,16 +106,16 @@ func main() { imitationSvc, kolWorker, app.Logger, - app.Config.Generation, - ).Start(ctx) + app.Config().Generation, + ).WithConfigProvider(app.ConfigStore).Start(ctx) generateworker.NewTemplateAssistWorker( app.RabbitMQ, templateSvc, app.Logger, - app.Config.Generation.WorkerConcurrency, - app.Config.Generation.ArticleTimeout, - ).Start(ctx) + app.Config().Generation.WorkerConcurrency, + app.Config().Generation.ArticleTimeout, + ).WithConfigProvider(app.ConfigStore).Start(ctx) app.Logger.Info("worker-generate started") diff --git a/server/go.mod b/server/go.mod index 5b27462..d4aea79 100644 --- a/server/go.mod +++ b/server/go.mod @@ -7,6 +7,7 @@ require ( github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/chai2010/webp v1.4.0 github.com/extrame/xls v0.0.1 + github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 @@ -15,6 +16,7 @@ require ( github.com/ledongthuc/pdf v0.0.0-20240314090751-a2a84ec735c3 github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308 github.com/minio/minio-go/v7 v7.0.83 + github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.20.5 github.com/prometheus/common v0.60.1 github.com/qdrant/go-client v1.13.0 @@ -22,7 +24,6 @@ require ( github.com/redis/go-redis/v9 v9.5.1 github.com/robfig/cron/v3 v3.0.1 github.com/sergi/go-diff v1.4.0 - github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 github.com/volcengine/volcengine-go-sdk v1.2.22 github.com/xuri/excelize/v2 v2.8.1 @@ -43,7 +44,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-ini/ini v1.67.0 // indirect @@ -51,7 +51,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.4 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect @@ -60,10 +59,8 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.2.4 // indirect - github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -75,13 +72,6 @@ require ( github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.3 // indirect github.com/rs/xid v1.6.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/volcengine/volc-sdk-golang v1.0.23 // indirect @@ -90,13 +80,11 @@ require ( github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect google.golang.org/grpc v1.66.0 // indirect google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/server/go.sum b/server/go.sum index 3bebbe9..40973bc 100644 --- a/server/go.sum +++ b/server/go.sum @@ -37,8 +37,6 @@ github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WR github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw= github.com/extrame/xls v0.0.1 h1:jI7L/o3z73TyyENPopsLS/Jlekm3nF1a/kF5hKBvy/k= github.com/extrame/xls v0.0.1/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -87,8 +85,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -125,8 +121,6 @@ github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308 h1:Qbh9Tg+EWuHW2o3Ux0ifWve+IRYWFAzSUt/90I2iRcE= github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260428110050-fedf5aaf0308/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8= -github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -176,22 +170,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -206,8 +186,6 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= @@ -237,8 +215,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -300,8 +276,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go index 8a6ab31..47f702c 100644 --- a/server/internal/bootstrap/bootstrap.go +++ b/server/internal/bootstrap/bootstrap.go @@ -31,39 +31,46 @@ import ( ) type App struct { - Config *config.Config - Logger *zap.Logger - DB *pgxpool.Pool - MonitoringDB *pgxpool.Pool - RabbitMQ *rabbitmq.Client - Redis *goredis.Client - Engine *gin.Engine - JWT *auth.Manager - Sessions *auth.SessionStore - LoginGuard *auth.LoginGuard - LLM llm.Client - RetrievalProvider retrieval.Provider - VectorStore retrieval.VectorStore - ObjectStorage objectstorage.Client - AuditLogs *auditlog.AsyncWriter - GenerationStreams *stream.GenerationHub - DesktopTaskStreams *stream.DesktopTaskHub - DesktopDispatch *stream.DesktopDispatchHub - Cache cache.Cache - KolProfiles repository.KolProfileRepository - KolPackages repository.KolPackageRepository - KolMarketplace repository.KolMarketplaceRepository - KolPrompts repository.KolPromptRepository - KolSubscriptions repository.KolSubscriptionRepository - KolAssists repository.KolAssistRepository - KolPromptAsset *tenantapp.KolPromptAsset + ConfigStore *config.Store + Logger *zap.Logger + DB *pgxpool.Pool + MonitoringDB *pgxpool.Pool + RabbitMQ *rabbitmq.Client + Redis *goredis.Client + Engine *gin.Engine + JWT *auth.Manager + Sessions *auth.SessionStore + LoginGuard *auth.LoginGuard + LLM llm.Client + ReloadableLLM *llm.ReloadableClient + RetrievalProvider retrieval.Provider + ReloadableRetrieval *retrieval.ReloadableProvider + VectorStore retrieval.VectorStore + ReloadableVectorStore *retrieval.ReloadableVectorStore + ObjectStorage objectstorage.Client + ReloadableObjectStorage *objectstorage.ReloadableClient + AuditLogs *auditlog.AsyncWriter + GenerationStreams *stream.GenerationHub + DesktopTaskStreams *stream.DesktopTaskHub + DesktopDispatch *stream.DesktopDispatchHub + Cache cache.Cache + BrandService *tenantapp.BrandService + MonitoringService *tenantapp.MonitoringService + KolProfiles repository.KolProfileRepository + KolPackages repository.KolPackageRepository + KolMarketplace repository.KolMarketplaceRepository + KolPrompts repository.KolPromptRepository + KolSubscriptions repository.KolSubscriptionRepository + KolAssists repository.KolAssistRepository + KolPromptAsset *tenantapp.KolPromptAsset } func New(configPath string) (*App, error) { - cfg, err := config.Load(configPath) + configStore, err := config.NewStore(configPath) if err != nil { return nil, fmt.Errorf("load config: %w", err) } + cfg := configStore.Current() prompts.SetConfigFile(filepath.Join(filepath.Dir(configPath), "prompts.yml")) logger, err := observability.NewLogger(cfg.Log.Level, cfg.Log.Format) @@ -106,10 +113,10 @@ func New(configPath string) (*App, error) { jwtMgr := auth.NewManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL) sessions := auth.NewSessionStore(rdb) loginGuard := auth.NewLoginGuard(rdb, auth.DefaultLoginGuardConfig("tenant")) - llmClient := llm.New(cfg.LLM) - retrievalProvider := retrieval.NewProvider(cfg.Retrieval, logger) - vectorStore := retrieval.NewQdrantStore(cfg.Qdrant, logger) - objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger) + llmClient := llm.NewReloadableClient(llm.New(cfg.LLM)) + retrievalProvider := retrieval.NewReloadableProvider(retrieval.NewProvider(cfg.Retrieval, logger)) + vectorStore := retrieval.NewReloadableVectorStore(retrieval.NewQdrantStore(cfg.Qdrant, logger)) + objectStorageClient := objectstorage.NewReloadableClient(objectstorage.New(cfg.ObjectStorage, logger)) auditLogs := auditlog.NewAsyncWriter(pool, logger) generationStreams := stream.NewGenerationHub(mqClient) desktopTaskStreams := stream.NewDesktopTaskHub(mqClient) @@ -120,6 +127,8 @@ func New(configPath string) (*App, error) { } desktopDispatch := stream.NewDesktopDispatchHub(mqClient, logger, desktopDispatchBindings) appCache := cache.New(cfg.Cache.Driver, rdb) + brandService := tenantapp.NewBrandService(pool, monitoringPool, auditLogs, cfg.BrandLibrary).WithCache(appCache) + monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb) kolProfiles := repository.NewKolProfileRepository(pool) kolPackages := repository.NewKolPackageRepository(pool) kolMarketplace := repository.NewKolMarketplaceRepository(pool) @@ -164,35 +173,93 @@ func New(configPath string) (*App, error) { }) return &App{ - Config: cfg, - Logger: logger, - DB: pool, - MonitoringDB: monitoringPool, - RabbitMQ: mqClient, - Redis: rdb, - Engine: engine, - JWT: jwtMgr, - Sessions: sessions, - LoginGuard: loginGuard, - LLM: llmClient, - RetrievalProvider: retrievalProvider, - VectorStore: vectorStore, - ObjectStorage: objectStorageClient, - AuditLogs: auditLogs, - GenerationStreams: generationStreams, - DesktopTaskStreams: desktopTaskStreams, - DesktopDispatch: desktopDispatch, - Cache: appCache, - KolProfiles: kolProfiles, - KolPackages: kolPackages, - KolMarketplace: kolMarketplace, - KolPrompts: kolPrompts, - KolSubscriptions: kolSubscriptions, - KolAssists: kolAssists, - KolPromptAsset: kolPromptAsset, + ConfigStore: configStore, + Logger: logger, + DB: pool, + MonitoringDB: monitoringPool, + RabbitMQ: mqClient, + Redis: rdb, + Engine: engine, + JWT: jwtMgr, + Sessions: sessions, + LoginGuard: loginGuard, + LLM: llmClient, + ReloadableLLM: llmClient, + RetrievalProvider: retrievalProvider, + ReloadableRetrieval: retrievalProvider, + VectorStore: vectorStore, + ReloadableVectorStore: vectorStore, + ObjectStorage: objectStorageClient, + ReloadableObjectStorage: objectStorageClient, + AuditLogs: auditLogs, + GenerationStreams: generationStreams, + DesktopTaskStreams: desktopTaskStreams, + DesktopDispatch: desktopDispatch, + Cache: appCache, + BrandService: brandService, + MonitoringService: monitoringService, + KolProfiles: kolProfiles, + KolPackages: kolPackages, + KolMarketplace: kolMarketplace, + KolPrompts: kolPrompts, + KolSubscriptions: kolSubscriptions, + KolAssists: kolAssists, + KolPromptAsset: kolPromptAsset, }, nil } +func (a *App) Config() *config.Config { + if a == nil || a.ConfigStore == nil { + return nil + } + return a.ConfigStore.Current() +} + +func (a *App) StartConfigWatcher(ctx context.Context) { + if a == nil || a.ConfigStore == nil { + return + } + go func() { + if err := a.ConfigStore.Watch(ctx, a.Logger, a.applyReloadedConfig); err != nil && a.Logger != nil { + a.Logger.Warn("config hot reload watcher stopped", zap.Error(err)) + } + }() +} + +func (a *App) applyReloadedConfig(event config.ReloadEvent) { + if a == nil || event.Current == nil { + return + } + + a.JWT.Update(event.Current.JWT.Secret, event.Current.JWT.AccessTTL, event.Current.JWT.RefreshTTL) + if a.ReloadableLLM != nil { + a.ReloadableLLM.Update(llm.New(event.Current.LLM)) + } + if a.ReloadableRetrieval != nil { + a.ReloadableRetrieval.Update(retrieval.NewProvider(event.Current.Retrieval, a.Logger)) + } + if a.ReloadableVectorStore != nil { + a.ReloadableVectorStore.Update(retrieval.NewQdrantStore(event.Current.Qdrant, a.Logger)) + } + if a.ReloadableObjectStorage != nil { + a.ReloadableObjectStorage.Update(objectstorage.New(event.Current.ObjectStorage, a.Logger)) + } + if a.BrandService != nil { + a.BrandService.UpdateConfig(event.Current.BrandLibrary) + } + if a.MonitoringService != nil { + a.MonitoringService.UpdateConfig(event.Current.MonitoringDispatch, event.Current.BrandLibrary) + } + + if err := syncMembershipPlans(context.Background(), a.DB, event.Current.Membership); err != nil { + a.Logger.Warn("config reload membership plan sync failed", zap.Error(err)) + } + + if config.RestartRequired(event.Changes) && a.Logger != nil { + a.Logger.Warn("config reload changed connection or listener settings; restart process to apply those fields") + } +} + // syncMembershipPlans upserts all configured plans in a single transaction so // that a partial failure (or concurrent startup) never leaves the `plans` table // in a half-synced state. @@ -212,6 +279,9 @@ func syncMembershipPlans(ctx context.Context, pool *pgxpool.Pool, membership con } func (a *App) Close() { + if a.ConfigStore != nil { + a.ConfigStore.Close() + } if a.AuditLogs != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) if err := a.AuditLogs.Close(ctx); err != nil { diff --git a/server/internal/ops/app/admin_user.go b/server/internal/ops/app/admin_user.go index 7b473d7..b4f5d87 100644 --- a/server/internal/ops/app/admin_user.go +++ b/server/internal/ops/app/admin_user.go @@ -6,6 +6,7 @@ import ( "net/mail" "regexp" "strings" + "sync" "time" "github.com/jackc/pgx/v5/pgconn" @@ -35,6 +36,7 @@ var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`) type AdminUserService struct { users *repository.AdminUserRepository audits *AuditService + configMu sync.RWMutex defaultPlanCode string } @@ -50,6 +52,31 @@ func NewAdminUserService(users *repository.AdminUserRepository, audits *AuditSer } } +func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) { + if s == nil { + return + } + defaultPlanCode = strings.TrimSpace(defaultPlanCode) + if defaultPlanCode == "" { + defaultPlanCode = "free" + } + s.configMu.Lock() + s.defaultPlanCode = defaultPlanCode + s.configMu.Unlock() +} + +func (s *AdminUserService) currentDefaultPlanCode() string { + if s == nil { + return "free" + } + s.configMu.RLock() + defer s.configMu.RUnlock() + if strings.TrimSpace(s.defaultPlanCode) == "" { + return "free" + } + return s.defaultPlanCode +} + type AdminUserView struct { ID int64 `json:"id"` Email *string `json:"email"` @@ -295,7 +322,7 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd } planCode := strings.TrimSpace(in.PlanCode) if planCode == "" { - planCode = s.defaultPlanCode + planCode = s.currentDefaultPlanCode() } role := strings.TrimSpace(in.Role) if role == "" { diff --git a/server/internal/ops/app/auth.go b/server/internal/ops/app/auth.go index 452d5d8..567ef0c 100644 --- a/server/internal/ops/app/auth.go +++ b/server/internal/ops/app/auth.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -26,15 +27,31 @@ type OperatorClaims struct { } type TokenIssuer struct { + mu sync.RWMutex secret []byte accessTTL time.Duration } func NewTokenIssuer(secret string, accessTTL time.Duration) *TokenIssuer { - return &TokenIssuer{secret: []byte(secret), accessTTL: accessTTL} + issuer := &TokenIssuer{} + issuer.Update(secret, accessTTL) + return issuer } -func (t *TokenIssuer) AccessTTL() time.Duration { return t.accessTTL } +func (t *TokenIssuer) Update(secret string, accessTTL time.Duration) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + t.secret = []byte(secret) + t.accessTTL = accessTTL +} + +func (t *TokenIssuer) AccessTTL() time.Duration { + _, accessTTL := t.snapshot() + return accessTTL +} type IssuedToken struct { AccessToken string @@ -42,8 +59,9 @@ type IssuedToken struct { } func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, error) { + secret, accessTTL := t.snapshot() now := time.Now() - exp := now.Add(t.accessTTL) + exp := now.Add(accessTTL) claims := OperatorClaims{ OperatorID: operator.ID, Username: operator.Username, @@ -56,7 +74,7 @@ func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, err ExpiresAt: jwt.NewNumericDate(exp), }, } - signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret) + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret) if err != nil { return nil, fmt.Errorf("sign ops token: %w", err) } @@ -64,11 +82,12 @@ func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, err } func (t *TokenIssuer) Parse(raw string) (*OperatorClaims, error) { + secret, _ := t.snapshot() token, err := jwt.ParseWithClaims(raw, &OperatorClaims{}, func(tok *jwt.Token) (any, error) { if _, ok := tok.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", tok.Header["alg"]) } - return t.secret, nil + return secret, nil }) if err != nil { return nil, err @@ -83,6 +102,16 @@ func (t *TokenIssuer) Parse(raw string) (*OperatorClaims, error) { return claims, nil } +func (t *TokenIssuer) snapshot() ([]byte, time.Duration) { + if t == nil { + return nil, 0 + } + t.mu.RLock() + defer t.mu.RUnlock() + secret := append([]byte(nil), t.secret...) + return secret, t.accessTTL +} + type AuthService struct { accounts *repository.AccountRepository audits *AuditService diff --git a/server/internal/ops/config/config.go b/server/internal/ops/config/config.go index 438fbe8..4607e9d 100644 --- a/server/internal/ops/config/config.go +++ b/server/internal/ops/config/config.go @@ -1,14 +1,21 @@ package config import ( + "context" "fmt" "os" + "path/filepath" "strings" + "sync" "time" - "github.com/spf13/viper" + "github.com/mitchellh/mapstructure" + "go.uber.org/zap" sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" + configruntime "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 Config struct { @@ -45,51 +52,324 @@ type IPRegionConfig struct { V6XDBPath string `mapstructure:"v6_xdb_path"` } +type Store struct { + path string + + mu sync.RWMutex + cfg *Config + runtime configruntime.Config + watchedFiles map[string]fileSnapshot + updates chan struct{} +} + +type ReloadEvent struct { + Previous *Config + Current *Config + Changes []FieldChange +} + +type FieldChange struct { + Path string + Hot bool +} + func Load(path string) (*Config, error) { - v := viper.New() - v.SetConfigFile(path) + cfg, err := load(path) + if err != nil { + return nil, err + } + return cfg, nil +} - v.SetEnvPrefix("OPS") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() +func NewStore(path string) (*Store, error) { + cfg, err := load(path) + if err != nil { + return nil, err + } + store := &Store{ + path: path, + cfg: cfg, + watchedFiles: snapshotFiles(watchFiles(path)), + updates: make(chan struct{}, 1), + } + store.runtime = store.newRuntimeConfig() + return store, nil +} - v.SetDefault("server.port", 8090) - v.SetDefault("server.mode", "debug") - v.SetDefault("redis.addr", "localhost:6379") - v.SetDefault("redis.db", 0) - v.SetDefault("cache.driver", "redis") - v.SetDefault("log.level", "info") - v.SetDefault("log.format", "json") - v.SetDefault("jwt.access_ttl", 8*time.Hour) - v.SetDefault("default_admin.username", "admin") - v.SetDefault("default_admin.display_name", "Administrator") - v.SetDefault("admin_users.default_plan_code", "free") - v.SetDefault("ip_region.v4_xdb_path", "") - v.SetDefault("ip_region.v6_xdb_path", "") +func (s *Store) Current() *Config { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg +} - if err := v.ReadInConfig(); err != nil { +func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error { + if s == nil { + return fmt.Errorf("ops config store is nil") + } + if s.runtime == nil { + s.runtime = s.newRuntimeConfig() + } + if err := s.runtime.Load(); err != nil { + return err + } + if err := s.runtime.Watch("", func(string, configruntime.Value) { + s.notifyReload() + }); err != nil { + _ = s.runtime.Close() + return err + } + if logger != nil { + logger.Info("ops config hot reload watcher started", zap.String("file", s.path)) + } + for { + select { + case <-ctx.Done(): + return nil + case <-s.updates: + s.reload(logger, onReload) + } + } +} + +func (s *Store) Close() { + if s == nil { + return + } + if s.runtime != nil { + _ = s.runtime.Close() + } +} + +func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) { + next, err := load(s.path) + if err != nil { + if logger != nil { + logger.Warn("ops config reload rejected", zap.Error(err)) + } + return + } + + s.mu.Lock() + previous := s.cfg + s.cfg = next + s.watchedFiles = snapshotFiles(watchFiles(s.path)) + s.mu.Unlock() + + event := ReloadEvent{ + Previous: previous, + Current: next, + Changes: Diff(previous, next), + } + if onReload != nil { + onReload(event) + } + if logger != nil { + restartRequired := RestartRequired(event.Changes) + logger.Info("ops config reloaded", zap.Bool("restart_required", restartRequired)) + if restartRequired { + logger.Warn("ops config reload changed connection or listener settings; restart process to apply those fields") + } + } +} + +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(watchFiles(s.path)) + 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) newRuntimeConfig() configruntime.Config { + return configruntime.New(configruntime.WithSource( + runtimefile.NewSource(filepath.Dir(s.path), watchNames(s.path)...), + runtimeenv.NewSource("OPS"), + )) +} + +func Diff(previous, current *Config) []FieldChange { + if previous == nil || current == nil { + return nil + } + changes := make([]FieldChange, 0) + add := func(path string, hot bool) { + changes = append(changes, FieldChange{Path: path, Hot: hot}) + } + + if previous.Server != current.Server { + add("server", false) + } + if previous.Database != current.Database { + add("database", false) + } + if previous.MonitoringDatabase != current.MonitoringDatabase { + add("monitoring_database", false) + } + if previous.Redis != current.Redis { + add("redis", false) + } + if previous.Cache != current.Cache { + add("cache", false) + } + if previous.Log != current.Log { + add("log", false) + } + if previous.IPRegion != current.IPRegion { + add("ip_region", false) + } + if previous.JWT != current.JWT { + add("jwt", true) + } + if previous.AdminUsers != current.AdminUsers { + add("admin_users", true) + } + if previous.DefaultAdmin != current.DefaultAdmin { + add("default_admin", true) + } + return changes +} + +func RestartRequired(changes []FieldChange) bool { + for _, change := range changes { + if !change.Hot { + return true + } + } + return false +} + +func load(path string) (*Config, error) { + resolved := configruntime.New(configruntime.WithSource( + runtimefile.NewSource(path), + runtimeenv.NewSource("OPS"), + )) + defer resolved.Close() + + if err := resolved.Load(); err != nil { return nil, fmt.Errorf("read config: %w", err) } + settings := defaultSettings() + if err := resolved.Scan(&settings); err != nil { + return nil, fmt.Errorf("scan config: %w", err) + } var cfg Config - if err := v.Unmarshal(&cfg); err != nil { + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Result: &cfg, + TagName: "mapstructure", + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + }) + if err != nil { + return nil, err + } + if err := decoder.Decode(settings); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) } if err := applyEnvOverrides(&cfg); err != nil { return nil, err } - + normalizeConfig(&cfg) if cfg.JWT.Secret == "" { return nil, fmt.Errorf("jwt.secret is required (set jwt.secret in yaml or OPS_JWT_SECRET env)") } if cfg.MonitoringDatabase.DBName == "" { cfg.MonitoringDatabase = cfg.Database } - return &cfg, nil } +func normalizeConfig(cfg *Config) { + if cfg.Server.Port == 0 { + cfg.Server.Port = 8090 + } + if strings.TrimSpace(cfg.Server.Mode) == "" { + cfg.Server.Mode = "debug" + } + if strings.TrimSpace(cfg.Redis.Addr) == "" { + cfg.Redis.Addr = "localhost:6379" + } + if strings.TrimSpace(cfg.Cache.Driver) == "" { + cfg.Cache.Driver = "redis" + } + if strings.TrimSpace(cfg.Log.Level) == "" { + cfg.Log.Level = "info" + } + if strings.TrimSpace(cfg.Log.Format) == "" { + cfg.Log.Format = "json" + } + if cfg.JWT.AccessTTL <= 0 { + cfg.JWT.AccessTTL = 8 * time.Hour + } + if strings.TrimSpace(cfg.DefaultAdmin.Username) == "" { + cfg.DefaultAdmin.Username = "admin" + } + if strings.TrimSpace(cfg.DefaultAdmin.DisplayName) == "" { + cfg.DefaultAdmin.DisplayName = "Administrator" + } + if strings.TrimSpace(cfg.AdminUsers.DefaultPlanCode) == "" { + cfg.AdminUsers.DefaultPlanCode = "free" + } +} + +func defaultSettings() map[string]any { + return map[string]any{ + "server": map[string]any{ + "port": 8090, + "mode": "debug", + }, + "redis": map[string]any{ + "addr": "localhost:6379", + "db": 0, + }, + "cache": map[string]any{ + "driver": "redis", + }, + "log": map[string]any{ + "level": "info", + "format": "json", + }, + "jwt": map[string]any{ + "access_ttl": 8 * time.Hour, + }, + "default_admin": map[string]any{ + "username": "admin", + "display_name": "Administrator", + }, + "admin_users": map[string]any{ + "default_plan_code": "free", + }, + "ip_region": map[string]any{ + "v4_xdb_path": "", + "v6_xdb_path": "", + }, + } +} + func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("OPS_JWT_SECRET"); v != "" { cfg.JWT.Secret = v @@ -117,3 +397,61 @@ func applyEnvOverrides(cfg *Config) error { } return nil } + +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 files { + if strings.TrimSpace(file) == "" { + continue + } + abs, err := filepath.Abs(file) + if err == nil { + file = abs + } + file = filepath.Clean(file) + 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 +} + +func watchFiles(path string) []string { + if strings.TrimSpace(path) == "" { + return nil + } + return []string{path} +} + +func watchNames(path string) []string { + if strings.TrimSpace(path) == "" { + return nil + } + return []string{filepath.Base(path)} +} diff --git a/server/internal/scheduler/schedule_dispatch_worker.go b/server/internal/scheduler/schedule_dispatch_worker.go index ffbafec..21d2f34 100644 --- a/server/internal/scheduler/schedule_dispatch_worker.go +++ b/server/internal/scheduler/schedule_dispatch_worker.go @@ -30,11 +30,8 @@ type ScheduleDispatchWorker struct { promptRuleService *tenantapp.PromptRuleGenerationService cache sharedcache.Cache logger *zap.Logger - interval time.Duration - timeout time.Duration - batchSize int - dispatchWorkers int - queueBackpressure int + cfg scheduleDispatchRuntimeConfig + configProvider config.Provider } type dueScheduleTask struct { @@ -62,6 +59,14 @@ type scheduleTaskCacheUpdate struct { tenantID int64 } +type scheduleDispatchRuntimeConfig struct { + Interval time.Duration + Timeout time.Duration + BatchSize int + DispatchWorkers int + QueueBackpressure int +} + func NewScheduleDispatchWorker( pool *pgxpool.Pool, rabbitMQClient *rabbitmq.Client, @@ -76,14 +81,27 @@ func NewScheduleDispatchWorker( promptRuleService: promptRuleService, cache: cache, logger: logger, - interval: schedulerDispatchInterval(cfg), - timeout: schedulerDispatchTimeout(cfg), - batchSize: schedulerDispatchBatchSize(cfg), - dispatchWorkers: schedulerDispatchConcurrency(cfg), - queueBackpressure: cfg.GenerationQueueBackpressureLimit, + cfg: scheduleDispatchRuntimeFromConfig(cfg), } } +func (w *ScheduleDispatchWorker) WithConfigProvider(provider config.Provider) *ScheduleDispatchWorker { + w.configProvider = provider + return w +} + +func (w *ScheduleDispatchWorker) runtimeConfig() scheduleDispatchRuntimeConfig { + if w != nil && w.configProvider != nil { + if cfg := w.configProvider.Current(); cfg != nil { + return scheduleDispatchRuntimeFromConfig(cfg.Scheduler) + } + } + if w == nil { + return scheduleDispatchRuntimeFromConfig(config.SchedulerConfig{}) + } + return w.cfg +} + func (w *ScheduleDispatchWorker) Start(ctx context.Context) { go w.Run(ctx) } @@ -98,14 +116,14 @@ func (w *ScheduleDispatchWorker) Run(ctx context.Context) { func (w *ScheduleDispatchWorker) run(ctx context.Context) { w.runOnce(context.Background()) - ticker := time.NewTicker(w.interval) - defer ticker.Stop() - for { + interval := w.runtimeConfig().Interval + timer := time.NewTimer(interval) select { case <-ctx.Done(): + timer.Stop() return - case <-ticker.C: + case <-timer.C: w.runOnce(context.Background()) } } @@ -132,10 +150,11 @@ func (w *ScheduleDispatchWorker) runOnce(parent context.Context) { } } else if paused { if w.logger != nil { + runtimeCfg := w.runtimeConfig() w.logger.Info("schedule dispatch paused by generation queue backpressure", zap.Int("queue_depth", stats.Messages), zap.Int("queue_consumers", stats.Consumers), - zap.Int("queue_limit", w.queueBackpressure), + zap.Int("queue_limit", runtimeCfg.QueueBackpressure), ) } return @@ -155,6 +174,7 @@ func (w *ScheduleDispatchWorker) runOnce(parent context.Context) { } func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (int, error) { + runtimeCfg := w.runtimeConfig() tx, err := w.pool.Begin(ctx) if err != nil { return 0, err @@ -170,7 +190,7 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in ORDER BY created_at ASC, id ASC FOR UPDATE SKIP LOCKED LIMIT $1 - `, w.batchSize) + `, runtimeCfg.BatchSize) if err != nil { return 0, err } @@ -180,7 +200,7 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in updates := make([]struct { cacheUpdate scheduleTaskCacheUpdate nextRunAt *time.Time - }, 0, w.batchSize) + }, 0, runtimeCfg.BatchSize) for rows.Next() { var ( id int64 @@ -248,6 +268,7 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in } func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueScheduleTask, error) { + runtimeCfg := w.runtimeConfig() tx, err := w.pool.Begin(ctx) if err != nil { return nil, err @@ -266,17 +287,17 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc ORDER BY next_run_at ASC, id ASC FOR UPDATE SKIP LOCKED LIMIT $2 - `, time.Now(), w.batchSize) + `, time.Now(), runtimeCfg.BatchSize) if err != nil { return nil, err } now := time.Now() - tasks := make([]dueScheduleTask, 0, w.batchSize) + tasks := make([]dueScheduleTask, 0, runtimeCfg.BatchSize) updates := make([]struct { cacheUpdate scheduleTaskCacheUpdate nextRun *time.Time - }, 0, w.batchSize) + }, 0, runtimeCfg.BatchSize) for rows.Next() { var ( task dueScheduleTask @@ -386,7 +407,7 @@ func (w *ScheduleDispatchWorker) dispatch(parent context.Context, task dueSchedu } for idx := 0; idx < generateCount; idx++ { - ctx, cancel := context.WithTimeout(parent, w.timeout) + ctx, cancel := w.stageContext(parent) resp, err := w.promptRuleService.EnqueueScheduledGeneration(ctx, tenantapp.SchedulePromptRuleGenerationInput{ ScheduleTaskID: task.ID, OperatorID: task.OperatorID, @@ -438,7 +459,8 @@ func (w *ScheduleDispatchWorker) dispatchBatch(parent context.Context, tasks []d if len(tasks) == 0 { return } - if w.dispatchWorkers <= 1 { + runtimeCfg := w.runtimeConfig() + if runtimeCfg.DispatchWorkers <= 1 { for _, task := range tasks { if parent.Err() != nil { return @@ -448,7 +470,7 @@ func (w *ScheduleDispatchWorker) dispatchBatch(parent context.Context, tasks []d return } - sem := make(chan struct{}, w.dispatchWorkers) + sem := make(chan struct{}, runtimeCfg.DispatchWorkers) var wg sync.WaitGroup for _, task := range tasks { if parent.Err() != nil { @@ -467,7 +489,11 @@ func (w *ScheduleDispatchWorker) dispatchBatch(parent context.Context, tasks []d } func (w *ScheduleDispatchWorker) shouldPauseDispatch(ctx context.Context) (bool, rabbitmq.QueueStats, error) { - if w == nil || w.rabbitMQ == nil || w.queueBackpressure <= 0 { + if w == nil || w.rabbitMQ == nil { + return false, rabbitmq.QueueStats{}, nil + } + runtimeCfg := w.runtimeConfig() + if runtimeCfg.QueueBackpressure <= 0 { return false, rabbitmq.QueueStats{}, nil } @@ -475,7 +501,17 @@ func (w *ScheduleDispatchWorker) shouldPauseDispatch(ctx context.Context) (bool, if err != nil { return false, rabbitmq.QueueStats{}, err } - return stats.Messages >= w.queueBackpressure, stats, nil + return stats.Messages >= runtimeCfg.QueueBackpressure, stats, nil +} + +func scheduleDispatchRuntimeFromConfig(cfg config.SchedulerConfig) scheduleDispatchRuntimeConfig { + return scheduleDispatchRuntimeConfig{ + Interval: schedulerDispatchInterval(cfg), + Timeout: schedulerDispatchTimeout(cfg), + BatchSize: schedulerDispatchBatchSize(cfg), + DispatchWorkers: schedulerDispatchConcurrency(cfg), + QueueBackpressure: cfg.GenerationQueueBackpressureLimit, + } } func schedulerDispatchInterval(cfg config.SchedulerConfig) time.Duration { @@ -510,7 +546,7 @@ func (w *ScheduleDispatchWorker) stageContext(parent context.Context) (context.C if parent == nil { parent = context.Background() } - return context.WithTimeout(parent, w.timeout) + return context.WithTimeout(parent, w.runtimeConfig().Timeout) } func (w *ScheduleDispatchWorker) invalidateScheduleTaskCaches(updates []scheduleTaskCacheUpdate) { diff --git a/server/internal/shared/auth/jwt.go b/server/internal/shared/auth/jwt.go index e3a76b5..8890cc2 100644 --- a/server/internal/shared/auth/jwt.go +++ b/server/internal/shared/auth/jwt.go @@ -3,6 +3,7 @@ package auth import ( "crypto/sha256" "fmt" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -10,17 +11,27 @@ import ( ) type Manager struct { + mu sync.RWMutex secret []byte accessTTL time.Duration refreshTTL time.Duration } func NewManager(secret string, accessTTL, refreshTTL time.Duration) *Manager { - return &Manager{ - secret: []byte(secret), - accessTTL: accessTTL, - refreshTTL: refreshTTL, + m := &Manager{} + m.Update(secret, accessTTL, refreshTTL) + return m +} + +func (m *Manager) Update(secret string, accessTTL, refreshTTL time.Duration) { + if m == nil { + return } + m.mu.Lock() + defer m.mu.Unlock() + m.secret = []byte(secret) + m.accessTTL = accessTTL + m.refreshTTL = refreshTTL } type TokenPair struct { @@ -32,6 +43,7 @@ type TokenPair struct { } func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) (*TokenPair, error) { + secret, accessTTL, refreshTTL := m.snapshot() now := time.Now() accessJTI := uuid.New().String() refreshJTI := uuid.New().String() @@ -45,10 +57,10 @@ func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) ID: accessJTI, Subject: "access", IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)), + ExpiresAt: jwt.NewNumericDate(now.Add(accessTTL)), }, } - accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(m.secret) + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(secret) if err != nil { return nil, fmt.Errorf("sign access token: %w", err) } @@ -62,10 +74,10 @@ func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) ID: refreshJTI, Subject: "refresh", IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(now.Add(m.refreshTTL)), + ExpiresAt: jwt.NewNumericDate(now.Add(refreshTTL)), }, } - refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(m.secret) + refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(secret) if err != nil { return nil, fmt.Errorf("sign refresh token: %w", err) } @@ -75,16 +87,17 @@ func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) RefreshToken: refreshToken, AccessJTI: accessJTI, RefreshJTI: refreshJTI, - ExpiresAt: now.Add(m.accessTTL).Unix(), + ExpiresAt: now.Add(accessTTL).Unix(), }, nil } func (m *Manager) Parse(tokenStr string) (*Claims, error) { + secret, _, _ := m.snapshot() token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } - return m.secret, nil + return secret, nil }) if err != nil { return nil, err @@ -96,8 +109,25 @@ func (m *Manager) Parse(tokenStr string) (*Claims, error) { return claims, nil } -func (m *Manager) AccessTTL() time.Duration { return m.accessTTL } -func (m *Manager) RefreshTTL() time.Duration { return m.refreshTTL } +func (m *Manager) AccessTTL() time.Duration { + _, ttl, _ := m.snapshot() + return ttl +} + +func (m *Manager) RefreshTTL() time.Duration { + _, _, ttl := m.snapshot() + return ttl +} + +func (m *Manager) snapshot() ([]byte, time.Duration, time.Duration) { + if m == nil { + return nil, 0, 0 + } + m.mu.RLock() + defer m.mu.RUnlock() + secret := append([]byte(nil), m.secret...) + return secret, m.accessTTL, m.refreshTTL +} func HashToken(raw string) string { h := sha256.Sum256([]byte(raw)) diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index f7a3e0a..ab694d1 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -7,7 +7,11 @@ import ( "strings" "time" - "github.com/spf13/viper" + "github.com/mitchellh/mapstructure" + + configruntime "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 Config struct { @@ -279,23 +283,28 @@ type GenerationConfig struct { } func Load(configPath string) (*Config, error) { - v := viper.New() - v.AutomaticEnv() - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + cfg, _, err := loadWithFiles(configPath) + return cfg, err +} - if err := readConfigWithFallback(v, configPath); err != nil { - return nil, fmt.Errorf("read config: %w", err) +func loadWithFiles(configPath string) (*Config, []string, error) { + configFile, err := readConfigWithFallback(configPath) + if err != nil { + return nil, nil, fmt.Errorf("read config: %w", err) } // Allow local overrides - _ = mergeLocalOverrideWithFallback(v, configPath) - - var cfg Config - if err := v.Unmarshal(&cfg); err != nil { - return nil, fmt.Errorf("unmarshal config: %w", err) + localConfigFile, err := mergeLocalOverrideWithFallback(configPath) + if err != nil { + return nil, nil, fmt.Errorf("merge local config: %w", err) } - applyEnvOverrides(&cfg) + cfg, err := decodeResolvedConfig(configFile, localConfigFile) + if err != nil { + return nil, nil, fmt.Errorf("unmarshal config: %w", err) + } + + applyEnvOverrides(cfg) normalizeRabbitMQConfig(&cfg.RabbitMQ) normalizeSchedulerConfig(&cfg.Scheduler) normalizeMonitoringConfig(&cfg.MonitoringWorkers) @@ -303,30 +312,72 @@ func Load(configPath string) (*Config, error) { normalizeMembershipConfig(&cfg.Membership) normalizeBrandLibraryConfig(&cfg.BrandLibrary) - return &cfg, nil + files := []string{configFile} + if localConfigFile != "" { + files = append(files, localConfigFile) + } + + return cfg, files, nil } -func readConfigWithFallback(v *viper.Viper, configPath string) error { +func readConfigWithFallback(configPath string) (string, error) { var lastErr error for _, candidate := range candidateConfigPaths(configPath, false) { - v.SetConfigFile(candidate) - if err := v.ReadInConfig(); err == nil { - return nil + if _, err := os.Stat(candidate); err == nil { + return candidate, nil } else { lastErr = err } } - return lastErr + return "", lastErr } -func mergeLocalOverrideWithFallback(v *viper.Viper, configPath string) error { +func mergeLocalOverrideWithFallback(configPath string) (string, error) { for _, candidate := range candidateConfigPaths(configPath, true) { - v.SetConfigFile(candidate) - if err := v.MergeInConfig(); err == nil { - return nil + if _, err := os.Stat(candidate); err != nil { + continue } + return candidate, nil } - return nil + return "", nil +} + +func decodeResolvedConfig(configFile, localConfigFile string) (*Config, error) { + sources := []configruntime.Source{runtimefile.NewSource(configFile)} + if localConfigFile != "" { + sources = append(sources, runtimefile.NewSource(localConfigFile)) + } + sources = append(sources, runtimeenv.NewSource()) + + resolved := configruntime.New(configruntime.WithSource(sources...)) + defer resolved.Close() + + if err := resolved.Load(); err != nil { + return nil, err + } + + settings := make(map[string]any) + if err := resolved.Scan(&settings); err != nil { + return nil, err + } + + var cfg Config + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Result: &cfg, + TagName: "mapstructure", + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + }) + if err != nil { + return nil, err + } + if err := decoder.Decode(settings); err != nil { + return nil, err + } + return &cfg, nil } func normalizeMonitoringDispatchConfig(cfg *MonitoringDispatchConfig) { diff --git a/server/internal/shared/config/config_test.go b/server/internal/shared/config/config_test.go index fb1d56b..1af78e0 100644 --- a/server/internal/shared/config/config_test.go +++ b/server/internal/shared/config/config_test.go @@ -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") + } + } +} diff --git a/server/internal/shared/config/provider.go b/server/internal/shared/config/provider.go new file mode 100644 index 0000000..3d7a895 --- /dev/null +++ b/server/internal/shared/config/provider.go @@ -0,0 +1,17 @@ +package config + +type Provider interface { + Current() *Config +} + +type staticProvider struct { + cfg *Config +} + +func NewStaticProvider(cfg *Config) Provider { + return staticProvider{cfg: cfg} +} + +func (p staticProvider) Current() *Config { + return p.cfg +} diff --git a/server/internal/shared/config/reload.go b/server/internal/shared/config/reload.go new file mode 100644 index 0000000..6aa4016 --- /dev/null +++ b/server/internal/shared/config/reload.go @@ -0,0 +1,112 @@ +package config + +import "reflect" + +type FieldChange struct { + Path string + Hot bool +} + +func Diff(previous, current *Config) []FieldChange { + if previous == nil || current == nil { + return nil + } + + changes := make([]FieldChange, 0) + addChange := func(path string, hot bool) { + changes = append(changes, FieldChange{Path: path, Hot: hot}) + } + + if previous.Server != current.Server { + addChange("server", false) + } + if previous.Database != current.Database { + addChange("database", false) + } + if previous.MonitoringDatabase != current.MonitoringDatabase { + addChange("monitoring_database", false) + } + if previous.RabbitMQ != current.RabbitMQ { + addChange("rabbitmq", false) + } + if previous.Scheduler.HTTPHost != current.Scheduler.HTTPHost || + previous.Scheduler.HTTPPort != current.Scheduler.HTTPPort { + addChange("scheduler.http", false) + } + if previous.Redis != current.Redis { + addChange("redis", false) + } + if previous.Cache != current.Cache { + addChange("cache", false) + } + if previous.Log != current.Log { + addChange("log", false) + } + + if previous.Scheduler.InternalMetricsToken != current.Scheduler.InternalMetricsToken { + addChange("scheduler.internal_metrics_token", true) + } + if previous.Scheduler.DispatchInterval != current.Scheduler.DispatchInterval || + previous.Scheduler.DispatchTimeout != current.Scheduler.DispatchTimeout || + previous.Scheduler.DispatchBatchSize != current.Scheduler.DispatchBatchSize || + previous.Scheduler.DispatchConcurrency != current.Scheduler.DispatchConcurrency || + previous.Scheduler.GenerationQueueBackpressureLimit != current.Scheduler.GenerationQueueBackpressureLimit { + addChange("scheduler.dispatch", true) + } + if previous.MonitoringWorkers != current.MonitoringWorkers { + addChange("monitoring_workers", true) + } + if previous.MonitoringDispatch != current.MonitoringDispatch { + addChange("monitoring_dispatch", true) + } + if !reflect.DeepEqual(previous.Membership, current.Membership) { + addChange("membership", true) + } + if previous.BrandLibrary != current.BrandLibrary { + addChange("brand_library", true) + } + if previous.Qdrant != current.Qdrant { + addChange("qdrant", true) + } + if previous.ObjectStorage != current.ObjectStorage { + addChange("object_storage", true) + } + if previous.JWT != current.JWT { + addChange("jwt", true) + } + if previous.LLM != current.LLM { + addChange("llm", true) + } + if previous.Retrieval != current.Retrieval { + addChange("retrieval", true) + } + if previous.Generation.QueueSize != current.Generation.QueueSize || + previous.Generation.WorkerConcurrency != current.Generation.WorkerConcurrency { + addChange("generation.worker", false) + } + if previous.Generation.StreamEnabled != current.Generation.StreamEnabled || + previous.Generation.ArticleTimeout != current.Generation.ArticleTimeout { + addChange("generation", true) + } + + return changes +} + +func RestartRequired(changes []FieldChange) bool { + for _, change := range changes { + if !change.Hot { + return true + } + } + return false +} + +func ChangePaths(changes []FieldChange, hot bool) []string { + paths := make([]string, 0, len(changes)) + for _, change := range changes { + if change.Hot == hot { + paths = append(paths, change.Path) + } + } + return paths +} diff --git a/server/internal/shared/config/runtime/LICENSE.kratos b/server/internal/shared/config/runtime/LICENSE.kratos new file mode 100644 index 0000000..684318c --- /dev/null +++ b/server/internal/shared/config/runtime/LICENSE.kratos @@ -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. diff --git a/server/internal/shared/config/runtime/config.go b/server/internal/shared/config/runtime/config.go new file mode 100644 index 0000000..539e46b --- /dev/null +++ b/server/internal/shared/config/runtime/config.go @@ -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 + } +} diff --git a/server/internal/shared/config/runtime/env/env.go b/server/internal/shared/config/runtime/env/env.go new file mode 100644 index 0000000..e681d83 --- /dev/null +++ b/server/internal/shared/config/runtime/env/env.go @@ -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 +} diff --git a/server/internal/shared/config/runtime/env/watcher.go b/server/internal/shared/config/runtime/env/watcher.go new file mode 100644 index 0000000..441f073 --- /dev/null +++ b/server/internal/shared/config/runtime/env/watcher.go @@ -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 +} diff --git a/server/internal/shared/config/runtime/file/file.go b/server/internal/shared/config/runtime/file/file.go new file mode 100644 index 0000000..804df7a --- /dev/null +++ b/server/internal/shared/config/runtime/file/file.go @@ -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 +} diff --git a/server/internal/shared/config/runtime/file/format.go b/server/internal/shared/config/runtime/file/format.go new file mode 100644 index 0000000..69c44c8 --- /dev/null +++ b/server/internal/shared/config/runtime/file/format.go @@ -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 "" +} diff --git a/server/internal/shared/config/runtime/file/watcher.go b/server/internal/shared/config/runtime/file/watcher.go new file mode 100644 index 0000000..efb93d4 --- /dev/null +++ b/server/internal/shared/config/runtime/file/watcher.go @@ -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 +} diff --git a/server/internal/shared/config/runtime/options.go b/server/internal/shared/config/runtime/options.go new file mode 100644 index 0000000..e911cfc --- /dev/null +++ b/server/internal/shared/config/runtime/options.go @@ -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 +} diff --git a/server/internal/shared/config/runtime/reader.go b/server/internal/shared/config/runtime/reader.go new file mode 100644 index 0000000..7c14f36 --- /dev/null +++ b/server/internal/shared/config/runtime/reader.go @@ -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) +} diff --git a/server/internal/shared/config/runtime/source.go b/server/internal/shared/config/runtime/source.go new file mode 100644 index 0000000..0a4d796 --- /dev/null +++ b/server/internal/shared/config/runtime/source.go @@ -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 +} diff --git a/server/internal/shared/config/runtime/value.go b/server/internal/shared/config/runtime/value.go new file mode 100644 index 0000000..cfdb4bd --- /dev/null +++ b/server/internal/shared/config/runtime/value.go @@ -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 } diff --git a/server/internal/shared/config/store.go b/server/internal/shared/config/store.go new file mode 100644 index 0000000..d4e61fb --- /dev/null +++ b/server/internal/shared/config/store.go @@ -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 +} diff --git a/server/internal/shared/llm/reloadable.go b/server/internal/shared/llm/reloadable.go new file mode 100644 index 0000000..a790541 --- /dev/null +++ b/server/internal/shared/llm/reloadable.go @@ -0,0 +1,44 @@ +package llm + +import ( + "context" + "sync" +) + +type ReloadableClient struct { + mu sync.RWMutex + current Client +} + +func NewReloadableClient(current Client) *ReloadableClient { + return &ReloadableClient{current: current} +} + +func (c *ReloadableClient) Update(next Client) { + if c == nil || next == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.current = next +} + +func (c *ReloadableClient) Validate() error { + return c.snapshot().Validate() +} + +func (c *ReloadableClient) Generate(ctx context.Context, req GenerateRequest, onDelta func(string)) (*GenerateResult, error) { + return c.snapshot().Generate(ctx, req, onDelta) +} + +func (c *ReloadableClient) snapshot() Client { + if c == nil { + return disabledClient{reason: "llm client is nil"} + } + c.mu.RLock() + defer c.mu.RUnlock() + if c.current == nil { + return disabledClient{reason: "llm client is not initialized"} + } + return c.current +} diff --git a/server/internal/shared/objectstorage/reloadable.go b/server/internal/shared/objectstorage/reloadable.go new file mode 100644 index 0000000..3f23095 --- /dev/null +++ b/server/internal/shared/objectstorage/reloadable.go @@ -0,0 +1,60 @@ +package objectstorage + +import ( + "context" + "sync" +) + +type ReloadableClient struct { + mu sync.RWMutex + current Client +} + +func NewReloadableClient(current Client) *ReloadableClient { + return &ReloadableClient{current: current} +} + +func (c *ReloadableClient) Update(next Client) { + if c == nil || next == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.current = next +} + +func (c *ReloadableClient) Validate() error { + return c.snapshot().Validate() +} + +func (c *ReloadableClient) PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error { + return c.snapshot().PutBytes(ctx, objectKey, content, contentType) +} + +func (c *ReloadableClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) { + return c.snapshot().GetBytes(ctx, objectKey) +} + +func (c *ReloadableClient) Exists(ctx context.Context, objectKey string) (bool, error) { + return c.snapshot().Exists(ctx, objectKey) +} + +func (c *ReloadableClient) Delete(ctx context.Context, objectKey string) error { + return c.snapshot().Delete(ctx, objectKey) +} + +func (c *ReloadableClient) PublicURL(objectKey string) (string, error) { + return c.snapshot().PublicURL(objectKey) +} + +func (c *ReloadableClient) snapshot() Client { + if c == nil { + return disabledClient{reason: "object storage client is nil"} + } + c.mu.RLock() + defer c.mu.RUnlock() + if c.current == nil { + return disabledClient{reason: "object storage client is not initialized"} + } + return c.current +} diff --git a/server/internal/shared/retrieval/reloadable.go b/server/internal/shared/retrieval/reloadable.go new file mode 100644 index 0000000..defc8a4 --- /dev/null +++ b/server/internal/shared/retrieval/reloadable.go @@ -0,0 +1,98 @@ +package retrieval + +import ( + "context" + "sync" +) + +type ReloadableProvider struct { + mu sync.RWMutex + current Provider +} + +func NewReloadableProvider(current Provider) *ReloadableProvider { + return &ReloadableProvider{current: current} +} + +func (p *ReloadableProvider) Update(next Provider) { + if p == nil || next == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.current = next +} + +func (p *ReloadableProvider) Validate() error { + return p.snapshot().Validate() +} + +func (p *ReloadableProvider) Embed(ctx context.Context, inputs []string) ([][]float64, error) { + return p.snapshot().Embed(ctx, inputs) +} + +func (p *ReloadableProvider) Rerank(ctx context.Context, query string, documents []string, topN int) ([]RerankResult, error) { + return p.snapshot().Rerank(ctx, query, documents, topN) +} + +func (p *ReloadableProvider) snapshot() Provider { + if p == nil { + return disabledProvider{reason: "retrieval provider is nil"} + } + p.mu.RLock() + defer p.mu.RUnlock() + if p.current == nil { + return disabledProvider{reason: "retrieval provider is not initialized"} + } + return p.current +} + +type ReloadableVectorStore struct { + mu sync.RWMutex + current VectorStore +} + +func NewReloadableVectorStore(current VectorStore) *ReloadableVectorStore { + return &ReloadableVectorStore{current: current} +} + +func (s *ReloadableVectorStore) Update(next VectorStore) { + if s == nil || next == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.current = next +} + +func (s *ReloadableVectorStore) Validate() error { + return s.snapshot().Validate() +} + +func (s *ReloadableVectorStore) EnsureCollection(ctx context.Context, vectorSize int) error { + return s.snapshot().EnsureCollection(ctx, vectorSize) +} + +func (s *ReloadableVectorStore) Upsert(ctx context.Context, points []VectorPoint) error { + return s.snapshot().Upsert(ctx, points) +} + +func (s *ReloadableVectorStore) Search(ctx context.Context, req SearchRequest) ([]SearchPoint, error) { + return s.snapshot().Search(ctx, req) +} + +func (s *ReloadableVectorStore) DeletePoints(ctx context.Context, pointIDs []string) error { + return s.snapshot().DeletePoints(ctx, pointIDs) +} + +func (s *ReloadableVectorStore) snapshot() VectorStore { + if s == nil { + return disabledVectorStore{reason: "vector store is nil"} + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.current == nil { + return disabledVectorStore{reason: "vector store is not initialized"} + } + return s.current +} diff --git a/server/internal/tenant/app/article_imitation_service.go b/server/internal/tenant/app/article_imitation_service.go index fd5c36d..2d07128 100644 --- a/server/internal/tenant/app/article_imitation_service.go +++ b/server/internal/tenant/app/article_imitation_service.go @@ -30,15 +30,14 @@ const ( ) type ArticleImitationService struct { - pool *pgxpool.Pool - llm llm.Client - rabbitMQ *rabbitmq.Client - knowledge *KnowledgeService - streams *stream.GenerationHub - streamEnabled bool - articleTimeout time.Duration - maxOutputTokens int64 - cache sharedcache.Cache + pool *pgxpool.Pool + llm llm.Client + rabbitMQ *rabbitmq.Client + knowledge *KnowledgeService + streams *stream.GenerationHub + cfg generationRuntimeConfig + configProvider runtimeConfigProvider + cache sharedcache.Cache } type GenerateImitationRequest struct { @@ -86,20 +85,13 @@ func NewArticleImitationService( cfg config.GenerationConfig, llmMaxOutputTokens int64, ) *ArticleImitationService { - articleTimeout := cfg.ArticleTimeout - if articleTimeout <= 0 { - articleTimeout = 8 * time.Minute - } - return &ArticleImitationService{ - pool: pool, - llm: llmClient, - rabbitMQ: rabbitMQClient, - knowledge: knowledge, - streams: streams, - streamEnabled: cfg.StreamEnabled, - articleTimeout: articleTimeout, - maxOutputTokens: llmMaxOutputTokens, + pool: pool, + llm: llmClient, + rabbitMQ: rabbitMQClient, + knowledge: knowledge, + streams: streams, + cfg: generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens}), } } @@ -108,6 +100,23 @@ func (s *ArticleImitationService) WithCache(c sharedcache.Cache) *ArticleImitati return s } +func (s *ArticleImitationService) WithConfigProvider(provider runtimeConfigProvider) *ArticleImitationService { + s.configProvider = provider + return s +} + +func (s *ArticleImitationService) runtimeConfig() generationRuntimeConfig { + if s != nil && s.configProvider != nil { + if cfg := s.configProvider.Current(); cfg != nil { + return generationRuntimeFromConfig(cfg.Generation, cfg.LLM) + } + } + if s == nil { + return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{}) + } + return s.cfg +} + func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImitationRequest) (*GenerateImitationResponse, error) { actor := auth.MustActor(ctx) if err := s.llm.Validate(); err != nil { @@ -234,7 +243,8 @@ func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImit return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry") } - if s.streamEnabled && s.streams != nil { + runtimeCfg := s.runtimeConfig() + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Start(articleID, taskID, initialTitle) } @@ -337,17 +347,18 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art } prompt := buildImitationGenerationPrompt(job.InputParams, sourceContent, knowledgePrompt) + runtimeCfg := s.runtimeConfig() req := llm.GenerateRequest{ Prompt: prompt, - Timeout: s.articleTimeout, - MaxOutputTokens: s.maxOutputTokens, + Timeout: runtimeCfg.ArticleTimeout, + MaxOutputTokens: runtimeCfg.MaxOutputTokens, } if job.WebSearch.Enabled { req.WebSearch = &job.WebSearch } result, err := s.llm.Generate(ctx, req, func(delta string) { - if s.streamEnabled && s.streams != nil && delta != "" { + if runtimeCfg.StreamEnabled && s.streams != nil && delta != "" { s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta) } }) @@ -429,7 +440,7 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art defer cancel() invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) - if s.streamEnabled && s.streams != nil { + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Complete(job.ArticleID, job.TaskID, title, content) } } @@ -490,7 +501,8 @@ func (s *ArticleImitationService) failGeneration(ctx context.Context, job articl invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) } - if s.streamEnabled && s.streams != nil && job.ArticleID > 0 { + runtimeCfg := s.runtimeConfig() + if runtimeCfg.StreamEnabled && s.streams != nil && job.ArticleID > 0 { s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg) } } diff --git a/server/internal/tenant/app/article_selection_optimize_service.go b/server/internal/tenant/app/article_selection_optimize_service.go index 4d2a4d0..64cb9d2 100644 --- a/server/internal/tenant/app/article_selection_optimize_service.go +++ b/server/internal/tenant/app/article_selection_optimize_service.go @@ -39,6 +39,7 @@ type ArticleSelectionOptimizeService struct { pool *pgxpool.Pool llm llm.Client defaultMaxOutTokens int64 + configProvider runtimeConfigProvider cache sharedcache.Cache } @@ -59,6 +60,11 @@ func (s *ArticleSelectionOptimizeService) WithCache(c sharedcache.Cache) *Articl return s } +func (s *ArticleSelectionOptimizeService) WithConfigProvider(provider runtimeConfigProvider) *ArticleSelectionOptimizeService { + s.configProvider = provider + return s +} + func (s *ArticleSelectionOptimizeService) ValidateRequest( ctx context.Context, articleID int64, @@ -174,6 +180,11 @@ func (s *ArticleSelectionOptimizeService) ensureArticleEditable( func (s *ArticleSelectionOptimizeService) resolveMaxOutputTokens(selectedText string) int64 { limit := s.defaultMaxOutTokens + if s != nil && s.configProvider != nil { + if cfg := s.configProvider.Current(); cfg != nil { + limit = cfg.LLM.MaxOutputTokens + } + } if limit <= 0 { limit = 1200 } diff --git a/server/internal/tenant/app/brand_service.go b/server/internal/tenant/app/brand_service.go index a83c0e8..e2bae1c 100644 --- a/server/internal/tenant/app/brand_service.go +++ b/server/internal/tenant/app/brand_service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/jackc/pgx/v5" @@ -24,6 +25,7 @@ import ( type BrandService struct { pool *pgxpool.Pool monitoringPool *pgxpool.Pool + limitsMu sync.RWMutex limits sharedconfig.BrandLibraryConfig auditLogs *auditlog.AsyncWriter cache sharedcache.Cache @@ -39,6 +41,24 @@ func (s *BrandService) WithCache(c sharedcache.Cache) *BrandService { return s } +func (s *BrandService) UpdateConfig(limits sharedconfig.BrandLibraryConfig) { + if s == nil { + return + } + s.limitsMu.Lock() + s.limits = limits + s.limitsMu.Unlock() +} + +func (s *BrandService) currentLimits() sharedconfig.BrandLibraryConfig { + if s == nil { + return sharedconfig.BrandLibraryConfig{} + } + s.limitsMu.RLock() + defer s.limitsMu.RUnlock() + return s.limits +} + // --- Brand CRUD --- type BrandRequest struct { @@ -809,11 +829,12 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int return nil, err } - maxBrands := s.limits.BrandLimitForPlan(plan.PlanCode) + limits := s.currentLimits() + maxBrands := limits.BrandLimitForPlan(plan.PlanCode) if plan.MaxBrands > 0 { maxBrands = plan.MaxBrands } - maxKeywords := s.limits.MaxKeywords + maxKeywords := limits.MaxKeywords return &BrandLibrarySummaryResponse{ PlanCode: plan.PlanCode, @@ -824,15 +845,16 @@ func (s *BrandService) loadBrandLibrarySummary(ctx context.Context, tenantID int MaxKeywords: maxKeywords, UsedKeywords: usage.KeywordCount, RemainingKeywords: maxInt(maxKeywords-usage.KeywordCount, 0), - MaxQuestionsPerKeyword: s.limits.MaxQuestionsPerKeyword, + MaxQuestionsPerKeyword: limits.MaxQuestionsPerKeyword, }, nil } func (s *BrandService) loadBrandLibraryPlan(ctx context.Context, tenantID int64) (*brandLibraryPlan, error) { + limits := s.currentLimits() plan := &brandLibraryPlan{ PlanCode: "free", PlanName: "", - MaxBrands: s.limits.BrandLimitForPlan("free"), + MaxBrands: limits.BrandLimitForPlan("free"), } var quotaPolicyJSON []byte diff --git a/server/internal/tenant/app/generation_task_runtime.go b/server/internal/tenant/app/generation_task_runtime.go index 806f5ae..2a9e651 100644 --- a/server/internal/tenant/app/generation_task_runtime.go +++ b/server/internal/tenant/app/generation_task_runtime.go @@ -107,10 +107,12 @@ func HandleClaimedGenerationTaskFailure( title = promptRuleGeneratingTitlePlaceholder } - if task.TaskType == "template" && templateService != nil && templateService.streamEnabled { + if task.TaskType == "template" && templateService != nil && + templateService.runtimeConfig().StreamEnabled && templateService.streams != nil { templateService.streams.Fail(task.ArticleID, task.ID, title, errMsg) } - if task.TaskType == "custom_generation" && promptRuleService != nil && promptRuleService.streamEnabled { + if task.TaskType == "custom_generation" && promptRuleService != nil && + promptRuleService.runtimeConfig().StreamEnabled && promptRuleService.streams != nil { promptRuleService.streams.Fail(task.ArticleID, task.ID, title, errMsg) } diff --git a/server/internal/tenant/app/knowledge_service.go b/server/internal/tenant/app/knowledge_service.go index 067d313..152797a 100644 --- a/server/internal/tenant/app/knowledge_service.go +++ b/server/internal/tenant/app/knowledge_service.go @@ -35,24 +35,18 @@ const ( ) type KnowledgeService struct { - pool *pgxpool.Pool - provider retrieval.Provider - store retrieval.VectorStore - objectStorage objectstorage.Client - llmClient llm.Client - logger *zap.Logger - chunkSize int - chunkOverlap int - embeddingBatchSize int - recallLimit int - rerankTopN int - arkBaseURL string - arkAPIKey string - urlMarkdownModel string - httpClient *http.Client - jobs chan knowledgeParseJob - cleanupMu sync.Mutex - cleanupInFlight map[string]struct{} + pool *pgxpool.Pool + provider retrieval.Provider + store retrieval.VectorStore + objectStorage objectstorage.Client + llmClient llm.Client + logger *zap.Logger + cfg knowledgeRuntimeConfig + configProvider runtimeConfigProvider + httpClient *http.Client + jobs chan knowledgeParseJob + cleanupMu sync.Mutex + cleanupInFlight map[string]struct{} } type knowledgeParseJob struct { @@ -169,50 +163,16 @@ func NewKnowledgeService( queueSize int, workerCount int, ) *KnowledgeService { - chunkSize := cfg.ChunkSize - if chunkSize <= 0 { - chunkSize = 900 - } - chunkOverlap := cfg.ChunkOverlap - if chunkOverlap < 0 { - chunkOverlap = 120 - } - batchSize := cfg.EmbeddingBatchSize - if batchSize <= 0 { - batchSize = 16 - } - recallLimit := cfg.RecallLimit - if recallLimit <= 0 { - recallLimit = 12 - } - rerankTopN := cfg.RerankTopN - if rerankTopN <= 0 { - rerankTopN = 6 - } - svc := &KnowledgeService{ - pool: pool, - provider: provider, - store: store, - objectStorage: objectStorage, - llmClient: llmClient, - logger: logger, - chunkSize: chunkSize, - chunkOverlap: chunkOverlap, - embeddingBatchSize: batchSize, - recallLimit: recallLimit, - rerankTopN: rerankTopN, - arkBaseURL: strings.TrimSpace(llmCfg.BaseURL), - arkAPIKey: strings.TrimSpace(llmCfg.APIKey), - urlMarkdownModel: strings.TrimSpace(llmCfg.KnowledgeURLModel), - httpClient: &http.Client{Timeout: defaultKnowledgeURLParseTimeout}, - cleanupInFlight: make(map[string]struct{}), - } - if svc.urlMarkdownModel == "" { - svc.urlMarkdownModel = strings.TrimSpace(llmCfg.Model) - } - if svc.arkBaseURL == "" { - svc.arkBaseURL = defaultKnowledgeArkBaseURL + pool: pool, + provider: provider, + store: store, + objectStorage: objectStorage, + llmClient: llmClient, + logger: logger, + cfg: knowledgeRuntimeFromConfig(cfg, llmCfg), + httpClient: &http.Client{Timeout: defaultKnowledgeURLParseTimeout}, + cleanupInFlight: make(map[string]struct{}), } if workerCount > 0 { @@ -229,6 +189,23 @@ func NewKnowledgeService( return svc } +func (s *KnowledgeService) WithConfigProvider(provider runtimeConfigProvider) *KnowledgeService { + s.configProvider = provider + return s +} + +func (s *KnowledgeService) runtimeConfig() knowledgeRuntimeConfig { + if s != nil && s.configProvider != nil { + if cfg := s.configProvider.Current(); cfg != nil { + return knowledgeRuntimeFromConfig(cfg.Retrieval, cfg.LLM) + } + } + if s == nil { + return knowledgeRuntimeFromConfig(config.RetrievalConfig{}, config.LLMConfig{}) + } + return s.cfg +} + func (s *KnowledgeService) ListGroups(ctx context.Context) ([]KnowledgeGroupResponse, error) { actor := auth.MustActor(ctx) rows, err := s.pool.Query(ctx, ` @@ -781,11 +758,12 @@ func (s *KnowledgeService) ResolveContext( return nil, response.ErrServiceUnavailable(50353, "embedding_failed", "embedding vector is empty") } + runtimeCfg := s.runtimeConfig() searchPoints, err := s.store.Search(ctx, retrieval.SearchRequest{ Vector: vectors[0], TenantID: tenantID, GroupIDs: searchGroupIDs, - Limit: s.recallLimit, + Limit: runtimeCfg.RecallLimit, }) if err != nil { return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error()) @@ -815,9 +793,9 @@ func (s *KnowledgeService) ResolveContext( documents = append(documents, candidate.Text) } - reranked, err := s.provider.Rerank(ctx, query, documents, minInt(s.rerankTopN, len(documents))) + reranked, err := s.provider.Rerank(ctx, query, documents, minInt(runtimeCfg.RerankTopN, len(documents))) if err != nil { - selected := selectKnowledgeCandidateSnippets(candidates, s.rerankTopN) + selected := selectKnowledgeCandidateSnippets(candidates, runtimeCfg.RerankTopN) selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected) baseContext.Snippets = selected baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected) @@ -825,7 +803,7 @@ func (s *KnowledgeService) ResolveContext( return baseContext, nil } - selected := selectKnowledgeRerankedSnippets(candidates, reranked, s.rerankTopN) + selected := selectKnowledgeRerankedSnippets(candidates, reranked, runtimeCfg.RerankTopN) selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected) baseContext.Snippets = selected baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected) @@ -1606,7 +1584,8 @@ func (s *KnowledgeService) validateIngestionDependencies(sourceType string) erro } } if sourceType == "website" { - if strings.TrimSpace(s.arkAPIKey) == "" { + runtimeCfg := s.runtimeConfig() + if strings.TrimSpace(runtimeCfg.ArkAPIKey) == "" { return response.ErrServiceUnavailable(50358, "knowledge_webpage_parser_unavailable", "ark api key is not configured") } if err := s.llmClient.Validate(); err != nil { @@ -1761,7 +1740,8 @@ func (s *KnowledgeService) executeParseJob(ctx context.Context, job knowledgePar chunkSource = strings.TrimSpace(parsedContent.Text) } - chunks := splitKnowledgeTextIntoChunks(chunkSource, s.chunkSize, s.chunkOverlap) + runtimeCfg := s.runtimeConfig() + chunks := splitKnowledgeTextIntoChunks(chunkSource, runtimeCfg.ChunkSize, runtimeCfg.ChunkOverlap) if len(chunks) == 0 { err = errors.New("knowledge content is empty after parsing") _ = s.markKnowledgeIngestionFailed(context.Background(), job.TenantID, job.ItemID, job.ParseTaskID, err) @@ -2371,9 +2351,10 @@ func (s *KnowledgeService) listActiveKnowledgeItemIDs( } func (s *KnowledgeService) embedChunks(ctx context.Context, chunks []string) ([][]float64, error) { + runtimeCfg := s.runtimeConfig() results := make([][]float64, 0, len(chunks)) - for start := 0; start < len(chunks); start += s.embeddingBatchSize { - end := start + s.embeddingBatchSize + for start := 0; start < len(chunks); start += runtimeCfg.EmbeddingBatchSize { + end := start + runtimeCfg.EmbeddingBatchSize if end > len(chunks) { end = len(chunks) } diff --git a/server/internal/tenant/app/knowledge_url_parser.go b/server/internal/tenant/app/knowledge_url_parser.go index 958c536..4f9b1c9 100644 --- a/server/internal/tenant/app/knowledge_url_parser.go +++ b/server/internal/tenant/app/knowledge_url_parser.go @@ -77,7 +77,8 @@ func (s *KnowledgeService) parseWebsiteKnowledge(ctx context.Context, rawURL str } func (s *KnowledgeService) executeLinkReader(ctx context.Context, rawURL string) (*arkWebDataItem, error) { - baseURL := strings.TrimRight(strings.TrimSpace(s.arkBaseURL), "/") + runtimeCfg := s.runtimeConfig() + baseURL := strings.TrimRight(strings.TrimSpace(runtimeCfg.ArkBaseURL), "/") if baseURL == "" { baseURL = defaultKnowledgeArkBaseURL } @@ -102,7 +103,7 @@ func (s *KnowledgeService) executeLinkReader(ctx context.Context, rawURL string) if err != nil { return nil, fmt.Errorf("create webpage parser request: %w", err) } - req.Header.Set("Authorization", "Bearer "+s.arkAPIKey) + req.Header.Set("Authorization", "Bearer "+runtimeCfg.ArkAPIKey) req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) @@ -146,9 +147,10 @@ func (s *KnowledgeService) formatWebsiteMarkdown(ctx context.Context, title, con } sections := make([]string, 0, len(chunks)+1) + runtimeCfg := s.runtimeConfig() for index, chunk := range chunks { result, err := s.llmClient.Generate(ctx, llm.GenerateRequest{ - Model: s.urlMarkdownModel, + Model: runtimeCfg.URLMarkdownModel, Prompt: prompts.KnowledgeWebsiteMarkdownPrompt(title, rawURL, chunk, index, len(chunks)), Timeout: defaultKnowledgeURLMarkdownTimeout, MaxOutputTokens: defaultKnowledgeURLMarkdownOutputToken, diff --git a/server/internal/tenant/app/kol_assist_generate_context.go b/server/internal/tenant/app/kol_assist_generate_context.go index 55276e2..f027777 100644 --- a/server/internal/tenant/app/kol_assist_generate_context.go +++ b/server/internal/tenant/app/kol_assist_generate_context.go @@ -19,10 +19,10 @@ const kolAssistReferenceContentMaxRunes = 6000 var kolAssistURLPattern = regexp.MustCompile(`https?://[^\s]+`) type kolAssistURLResolver struct { - arkBaseURL string - arkAPIKey string - httpClient *http.Client - logger *zap.Logger + cfg config.LLMConfig + configProvider runtimeConfigProvider + httpClient *http.Client + logger *zap.Logger } type kolAssistReferenceMaterial struct { @@ -38,17 +38,25 @@ func newKolAssistURLResolver(llmCfg config.LLMConfig, logger *zap.Logger) *kolAs } return &kolAssistURLResolver{ - arkBaseURL: strings.TrimRight(baseURL, "/"), - arkAPIKey: strings.TrimSpace(llmCfg.APIKey), + cfg: config.LLMConfig{ + BaseURL: baseURL, + APIKey: strings.TrimSpace(llmCfg.APIKey), + }, httpClient: &http.Client{Timeout: defaultKnowledgeURLParseTimeout}, logger: logger, } } func (r *kolAssistURLResolver) extract(ctx context.Context, rawURL string) (*kolAssistReferenceMaterial, error) { - if r == nil || r.httpClient == nil || strings.TrimSpace(r.arkAPIKey) == "" { + llmCfg := r.runtimeConfig() + apiKey := strings.TrimSpace(llmCfg.APIKey) + if r == nil || r.httpClient == nil || apiKey == "" { return nil, fmt.Errorf("url resolver is unavailable") } + baseURL := strings.TrimRight(strings.TrimSpace(llmCfg.BaseURL), "/") + if baseURL == "" { + baseURL = defaultKnowledgeArkBaseURL + } body, err := json.Marshal(arkToolExecuteRequest{ ActionName: "LinkReader", @@ -62,11 +70,11 @@ func (r *kolAssistURLResolver) extract(ctx context.Context, rawURL string) (*kol return nil, fmt.Errorf("marshal webpage parser request: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.arkBaseURL+"/tools/execute", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/tools/execute", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create webpage parser request: %w", err) } - req.Header.Set("Authorization", "Bearer "+r.arkAPIKey) + req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") resp, err := r.httpClient.Do(req) @@ -108,6 +116,26 @@ func (r *kolAssistURLResolver) extract(ctx context.Context, rawURL string) (*kol }, nil } +func (r *kolAssistURLResolver) withConfigProvider(provider runtimeConfigProvider) *kolAssistURLResolver { + if r == nil { + return nil + } + r.configProvider = provider + return r +} + +func (r *kolAssistURLResolver) runtimeConfig() config.LLMConfig { + if r != nil && r.configProvider != nil { + if cfg := r.configProvider.Current(); cfg != nil { + return cfg.LLM + } + } + if r == nil { + return config.LLMConfig{BaseURL: defaultKnowledgeArkBaseURL} + } + return r.cfg +} + func (s *KolAssistService) enrichGenerateDescription(ctx context.Context, description string) string { description = strings.TrimSpace(description) urls := extractKolAssistURLs(description) diff --git a/server/internal/tenant/app/kol_assist_service.go b/server/internal/tenant/app/kol_assist_service.go index dc54c48..7b4a495 100644 --- a/server/internal/tenant/app/kol_assist_service.go +++ b/server/internal/tenant/app/kol_assist_service.go @@ -85,6 +85,13 @@ func (s *KolAssistService) WithCache(c sharedcache.Cache) *KolAssistService { return s } +func (s *KolAssistService) WithConfigProvider(provider runtimeConfigProvider) *KolAssistService { + if s != nil && s.urlResolver != nil { + s.urlResolver.withConfigProvider(provider) + } + return s +} + func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req AssistRequest) (string, error) { profile, err := s.profileSvc.RequireActiveKol(ctx, actor) if err != nil { diff --git a/server/internal/tenant/app/monitoring_daily_task_worker.go b/server/internal/tenant/app/monitoring_daily_task_worker.go index bcf638d..07673b9 100644 --- a/server/internal/tenant/app/monitoring_daily_task_worker.go +++ b/server/internal/tenant/app/monitoring_daily_task_worker.go @@ -482,9 +482,10 @@ func (s *MonitoringService) generateDailyMonitoringTasksForBrand( } func (s *MonitoringService) loadDailyMonitoringPlans(ctx context.Context) ([]monitoringDailyPlan, error) { - freeBrandLimit := normalizeMonitoringDailyBrandLimit(s.brandLibraryConfig.FreeBrandLimit) - paidBrandLimit := normalizeMonitoringDailyBrandLimit(s.brandLibraryConfig.PaidBrandLimit) - questionLimit := monitoringDailyQuestionLimit(s.brandLibraryConfig) + _, brandLibraryConfig := s.currentConfig() + freeBrandLimit := normalizeMonitoringDailyBrandLimit(brandLibraryConfig.FreeBrandLimit) + paidBrandLimit := normalizeMonitoringDailyBrandLimit(brandLibraryConfig.PaidBrandLimit) + questionLimit := monitoringDailyQuestionLimit(brandLibraryConfig) supportedPlatforms := monitoringPlatformIDs(defaultMonitoringPlatforms) candidates, err := s.loadDailyMonitoringPrimaryClientPlans(ctx, freeBrandLimit, paidBrandLimit, questionLimit, supportedPlatforms) diff --git a/server/internal/tenant/app/monitoring_phase2_desktop_tasks.go b/server/internal/tenant/app/monitoring_phase2_desktop_tasks.go index 4ea9759..6098a31 100644 --- a/server/internal/tenant/app/monitoring_phase2_desktop_tasks.go +++ b/server/internal/tenant/app/monitoring_phase2_desktop_tasks.go @@ -85,17 +85,18 @@ func (s *MonitoringService) shouldUseDesktopTasksExecution(tenantID int64) bool if s == nil { return false } - if s.desktopTasksRolloutPercentage <= 0 { + rolloutPercentage, _ := s.currentConfig() + if rolloutPercentage <= 0 { return false } - if s.desktopTasksRolloutPercentage >= 100 { + if rolloutPercentage >= 100 { return true } mod := tenantID % 100 if mod < 0 { mod = -mod } - return int(mod) < s.desktopTasksRolloutPercentage + return int(mod) < rolloutPercentage } func (s *MonitoringService) loadMonitorDesktopTaskSpecs( diff --git a/server/internal/tenant/app/monitoring_service.go b/server/internal/tenant/app/monitoring_service.go index 73574e6..82a696f 100644 --- a/server/internal/tenant/app/monitoring_service.go +++ b/server/internal/tenant/app/monitoring_service.go @@ -9,6 +9,7 @@ import ( "fmt" "sort" "strings" + "sync" "time" "github.com/google/uuid" @@ -43,6 +44,7 @@ type MonitoringService struct { rabbitMQ *rabbitmq.Client redis *goredis.Client logger *zap.Logger + configMu sync.RWMutex desktopTasksRolloutPercentage int brandLibraryConfig config.BrandLibraryConfig } @@ -71,6 +73,25 @@ func (s *MonitoringService) WithRedis(redis *goredis.Client) *MonitoringService return s } +func (s *MonitoringService) UpdateConfig(dispatchCfg config.MonitoringDispatchConfig, brandLibraryCfg config.BrandLibraryConfig) { + if s == nil { + return + } + s.configMu.Lock() + s.desktopTasksRolloutPercentage = dispatchCfg.DesktopTasksRolloutPercentage + s.brandLibraryConfig = brandLibraryCfg + s.configMu.Unlock() +} + +func (s *MonitoringService) currentConfig() (int, config.BrandLibraryConfig) { + if s == nil { + return 0, config.BrandLibraryConfig{} + } + s.configMu.RLock() + defer s.configMu.RUnlock() + return s.desktopTasksRolloutPercentage, s.brandLibraryConfig +} + type MonitoringDashboardCompositeResponse struct { Overview MonitoringOverview `json:"overview"` Runtime MonitoringDashboardRuntime `json:"runtime"` diff --git a/server/internal/tenant/app/prompt_generate_service.go b/server/internal/tenant/app/prompt_generate_service.go index 7f382eb..10146c7 100644 --- a/server/internal/tenant/app/prompt_generate_service.go +++ b/server/internal/tenant/app/prompt_generate_service.go @@ -23,17 +23,16 @@ import ( ) type PromptRuleGenerationService struct { - pool *pgxpool.Pool - llm llm.Client - rabbitMQ *rabbitmq.Client - knowledge *KnowledgeService - streams *stream.GenerationHub - streamEnabled bool - articleTimeout time.Duration - maxOutputTokens int64 - cache sharedcache.Cache - publishJobs *PublishJobService - logger *zap.Logger + pool *pgxpool.Pool + llm llm.Client + rabbitMQ *rabbitmq.Client + knowledge *KnowledgeService + streams *stream.GenerationHub + cfg generationRuntimeConfig + configProvider runtimeConfigProvider + cache sharedcache.Cache + publishJobs *PublishJobService + logger *zap.Logger } type promptRuleGenerationJob struct { @@ -119,20 +118,13 @@ func NewPromptRuleGenerationService( cfg config.GenerationConfig, llmMaxOutputTokens int64, ) *PromptRuleGenerationService { - articleTimeout := cfg.ArticleTimeout - if articleTimeout <= 0 { - articleTimeout = 8 * time.Minute - } - svc := &PromptRuleGenerationService{ - pool: pool, - llm: llmClient, - rabbitMQ: rabbitMQClient, - knowledge: knowledge, - streams: streams, - streamEnabled: cfg.StreamEnabled, - articleTimeout: articleTimeout, - maxOutputTokens: llmMaxOutputTokens, + pool: pool, + llm: llmClient, + rabbitMQ: rabbitMQClient, + knowledge: knowledge, + streams: streams, + cfg: generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens}), } return svc @@ -153,6 +145,23 @@ func (s *PromptRuleGenerationService) WithLogger(logger *zap.Logger) *PromptRule return s } +func (s *PromptRuleGenerationService) WithConfigProvider(provider runtimeConfigProvider) *PromptRuleGenerationService { + s.configProvider = provider + return s +} + +func (s *PromptRuleGenerationService) runtimeConfig() generationRuntimeConfig { + if s != nil && s.configProvider != nil { + if cfg := s.configProvider.Current(); cfg != nil { + return generationRuntimeFromConfig(cfg.Generation, cfg.LLM) + } + } + if s == nil { + return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{}) + } + return s.cfg +} + func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req GenerateFromRuleRequest) (*GenerateFromRuleResponse, error) { actor := auth.MustActor(ctx) extra := clonePromptRuleExtraParams(req.ExtraParams) @@ -440,7 +449,8 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry") } - if s.streamEnabled && s.streams != nil { + runtimeCfg := s.runtimeConfig() + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Start(articleID, taskID, promptRuleGeneratingTitlePlaceholder) } @@ -505,17 +515,18 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job StartedAt: &now, }) + runtimeCfg := s.runtimeConfig() req := llm.GenerateRequest{ Prompt: job.Prompt, - Timeout: s.articleTimeout, - MaxOutputTokens: s.maxOutputTokens, + Timeout: runtimeCfg.ArticleTimeout, + MaxOutputTokens: runtimeCfg.MaxOutputTokens, } if job.WebSearch.Enabled { req.WebSearch = &job.WebSearch } result, err := s.llm.Generate(ctx, req, func(delta string) { - if s.streamEnabled && delta != "" { + if runtimeCfg.StreamEnabled && s.streams != nil && delta != "" { s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta) } }) @@ -596,7 +607,7 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job defer cancel() invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) - if s.streamEnabled { + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Complete(job.ArticleID, job.TaskID, title, content) } @@ -721,7 +732,8 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr _ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID) invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) - if s.streamEnabled { + runtimeCfg := s.runtimeConfig() + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Fail(job.ArticleID, job.TaskID, failureTitle, errMsg) } } diff --git a/server/internal/tenant/app/runtime_config.go b/server/internal/tenant/app/runtime_config.go new file mode 100644 index 0000000..cd7c38b --- /dev/null +++ b/server/internal/tenant/app/runtime_config.go @@ -0,0 +1,105 @@ +package app + +import ( + "strings" + "time" + + "github.com/geo-platform/tenant-api/internal/shared/config" +) + +const defaultArticleGenerationTimeout = 8 * time.Minute + +type runtimeConfigProvider = config.Provider + +type generationRuntimeConfig struct { + StreamEnabled bool + ArticleTimeout time.Duration + MaxOutputTokens int64 +} + +type knowledgeRuntimeConfig struct { + ChunkSize int + ChunkOverlap int + EmbeddingBatchSize int + RecallLimit int + RerankTopN int + ArkBaseURL string + ArkAPIKey string + URLMarkdownModel string +} + +type generationStreamDisabledConfigProvider struct { + base config.Provider +} + +func NewGenerationStreamDisabledConfigProvider(base config.Provider) config.Provider { + return generationStreamDisabledConfigProvider{base: base} +} + +func (p generationStreamDisabledConfigProvider) Current() *config.Config { + if p.base == nil { + return nil + } + cfg := p.base.Current() + if cfg == nil { + return nil + } + next := *cfg + next.Generation.StreamEnabled = false + return &next +} + +func generationRuntimeFromConfig(generation config.GenerationConfig, llmCfg config.LLMConfig) generationRuntimeConfig { + articleTimeout := generation.ArticleTimeout + if articleTimeout <= 0 { + articleTimeout = defaultArticleGenerationTimeout + } + return generationRuntimeConfig{ + StreamEnabled: generation.StreamEnabled, + ArticleTimeout: articleTimeout, + MaxOutputTokens: llmCfg.MaxOutputTokens, + } +} + +func knowledgeRuntimeFromConfig(retrievalCfg config.RetrievalConfig, llmCfg config.LLMConfig) knowledgeRuntimeConfig { + chunkSize := retrievalCfg.ChunkSize + if chunkSize <= 0 { + chunkSize = 900 + } + chunkOverlap := retrievalCfg.ChunkOverlap + if chunkOverlap < 0 { + chunkOverlap = 120 + } + batchSize := retrievalCfg.EmbeddingBatchSize + if batchSize <= 0 { + batchSize = 16 + } + recallLimit := retrievalCfg.RecallLimit + if recallLimit <= 0 { + recallLimit = 12 + } + rerankTopN := retrievalCfg.RerankTopN + if rerankTopN <= 0 { + rerankTopN = 6 + } + + model := strings.TrimSpace(llmCfg.KnowledgeURLModel) + if model == "" { + model = strings.TrimSpace(llmCfg.Model) + } + baseURL := strings.TrimSpace(llmCfg.BaseURL) + if baseURL == "" { + baseURL = defaultKnowledgeArkBaseURL + } + + return knowledgeRuntimeConfig{ + ChunkSize: chunkSize, + ChunkOverlap: chunkOverlap, + EmbeddingBatchSize: batchSize, + RecallLimit: recallLimit, + RerankTopN: rerankTopN, + ArkBaseURL: baseURL, + ArkAPIKey: strings.TrimSpace(llmCfg.APIKey), + URLMarkdownModel: model, + } +} diff --git a/server/internal/tenant/app/template_service.go b/server/internal/tenant/app/template_service.go index a5539c9..2491e56 100644 --- a/server/internal/tenant/app/template_service.go +++ b/server/internal/tenant/app/template_service.go @@ -22,16 +22,15 @@ import ( ) type TemplateService struct { - pool *pgxpool.Pool - templates repository.TemplateRepository - llm llm.Client - rabbitMQ *rabbitmq.Client - knowledge *KnowledgeService - streams *stream.GenerationHub - streamEnabled bool - articleTimeout time.Duration - maxOutputTokens int64 - cache sharedcache.Cache + pool *pgxpool.Pool + templates repository.TemplateRepository + llm llm.Client + rabbitMQ *rabbitmq.Client + knowledge *KnowledgeService + streams *stream.GenerationHub + cfg generationRuntimeConfig + configProvider runtimeConfigProvider + cache sharedcache.Cache } type generationJob struct { @@ -58,21 +57,14 @@ func NewTemplateService( cfg config.GenerationConfig, llmMaxOutputTokens int64, ) *TemplateService { - articleTimeout := cfg.ArticleTimeout - if articleTimeout <= 0 { - articleTimeout = 8 * time.Minute - } - svc := &TemplateService{ - pool: pool, - templates: templates, - llm: llmClient, - rabbitMQ: rabbitMQClient, - knowledge: knowledge, - streams: streams, - streamEnabled: cfg.StreamEnabled, - articleTimeout: articleTimeout, - maxOutputTokens: llmMaxOutputTokens, + pool: pool, + templates: templates, + llm: llmClient, + rabbitMQ: rabbitMQClient, + knowledge: knowledge, + streams: streams, + cfg: generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens}), } return svc @@ -83,6 +75,23 @@ func (s *TemplateService) WithCache(c sharedcache.Cache) *TemplateService { return s } +func (s *TemplateService) WithConfigProvider(provider runtimeConfigProvider) *TemplateService { + s.configProvider = provider + return s +} + +func (s *TemplateService) runtimeConfig() generationRuntimeConfig { + if s != nil && s.configProvider != nil { + if cfg := s.configProvider.Current(); cfg != nil { + return generationRuntimeFromConfig(cfg.Generation, cfg.LLM) + } + } + if s == nil { + return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{}) + } + return s.cfg +} + type TemplateListItem struct { ID int64 `json:"id"` Scope string `json:"scope"` @@ -397,7 +406,8 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry") } - if s.streamEnabled { + runtimeCfg := s.runtimeConfig() + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Start(articleID, taskID, initialTitle) } @@ -467,16 +477,17 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ } prompt := buildGenerationPrompt(job.TemplateKey, job.TemplateName, job.PromptTemplate, job.Params, knowledgePrompt) + runtimeCfg := s.runtimeConfig() generateReq := llm.GenerateRequest{ Prompt: prompt, - Timeout: s.articleTimeout, - MaxOutputTokens: s.maxOutputTokens, + Timeout: runtimeCfg.ArticleTimeout, + MaxOutputTokens: runtimeCfg.MaxOutputTokens, } if job.WebSearch.Enabled { generateReq.WebSearch = &job.WebSearch } result, err := s.llm.Generate(ctx, generateReq, func(delta string) { - if s.streamEnabled && delta != "" { + if runtimeCfg.StreamEnabled && delta != "" { s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta) } }) @@ -557,7 +568,7 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ defer cancel() invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) - if s.streamEnabled { + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Complete(job.ArticleID, job.TaskID, title, content) } } @@ -602,7 +613,8 @@ func (s *TemplateService) failGeneration(ctx context.Context, job generationJob, _ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID) invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID) - if s.streamEnabled { + runtimeCfg := s.runtimeConfig() + if runtimeCfg.StreamEnabled && s.streams != nil { s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg) } } diff --git a/server/internal/tenant/transport/article_handler.go b/server/internal/tenant/transport/article_handler.go index 26dcac6..293266a 100644 --- a/server/internal/tenant/transport/article_handler.go +++ b/server/internal/tenant/transport/article_handler.go @@ -24,7 +24,7 @@ type ArticleHandler struct { promptGenerateSvc *app.PromptRuleGenerationService imitationSvc *app.ArticleImitationService streams *stream.GenerationHub - streamEnabled bool + app *bootstrap.App assets *AssetHandler } @@ -36,40 +36,40 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler { a.ObjectStorage, a.LLM, a.Logger, - a.Config.Retrieval, - a.Config.LLM, + a.Config().Retrieval, + a.Config().LLM, 0, 0, - ) + ).WithConfigProvider(a.ConfigStore) return &ArticleHandler{ svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).WithCache(a.Cache), selectionOptimize: app.NewArticleSelectionOptimizeService( a.DB, a.LLM, - a.Config.LLM.MaxOutputTokens, - ).WithCache(a.Cache), + a.Config().LLM.MaxOutputTokens, + ).WithCache(a.Cache).WithConfigProvider(a.ConfigStore), promptGenerateSvc: app.NewPromptRuleGenerationService( a.DB, a.LLM, a.RabbitMQ, knowledgeSvc, a.GenerationStreams, - a.Config.Generation, - a.Config.LLM.MaxOutputTokens, - ).WithCache(a.Cache).WithLogger(a.Logger), + a.Config().Generation, + a.Config().LLM.MaxOutputTokens, + ).WithCache(a.Cache).WithLogger(a.Logger).WithConfigProvider(a.ConfigStore), imitationSvc: app.NewArticleImitationService( a.DB, a.LLM, a.RabbitMQ, knowledgeSvc, a.GenerationStreams, - a.Config.Generation, - a.Config.LLM.MaxOutputTokens, - ).WithCache(a.Cache), - streams: a.GenerationStreams, - streamEnabled: a.Config.Generation.StreamEnabled, - assets: NewAssetHandler(a), + a.Config().Generation, + a.Config().LLM.MaxOutputTokens, + ).WithCache(a.Cache).WithConfigProvider(a.ConfigStore), + streams: a.GenerationStreams, + app: a, + assets: NewAssetHandler(a), } } @@ -396,7 +396,8 @@ func (h *ArticleHandler) OptimizeSelectionStream(c *gin.Context) { } func (h *ArticleHandler) Stream(c *gin.Context) { - if !h.streamEnabled { + cfg := h.app.Config() + if cfg == nil || !cfg.Generation.StreamEnabled { response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled")) return } diff --git a/server/internal/tenant/transport/asset_handler.go b/server/internal/tenant/transport/asset_handler.go index cadad50..97e652d 100644 --- a/server/internal/tenant/transport/asset_handler.go +++ b/server/internal/tenant/transport/asset_handler.go @@ -20,15 +20,11 @@ import ( ) type AssetHandler struct { - secret string - app *bootstrap.App + app *bootstrap.App } func NewAssetHandler(app *bootstrap.App) *AssetHandler { - return &AssetHandler{ - secret: strings.TrimSpace(app.Config.JWT.Secret), - app: app, - } + return &AssetHandler{app: app} } func (h *AssetHandler) BuildArticleImageURL(objectKey string) string { @@ -109,12 +105,19 @@ func convertPublicAssetFormat(content []byte, format string) ([]byte, string, bo func (h *AssetHandler) signObjectKey(objectKey string) string { encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey)) - mac := hmac.New(sha256.New, []byte(h.secret)) + mac := hmac.New(sha256.New, []byte(h.secret())) _, _ = mac.Write([]byte(objectKey)) signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return fmt.Sprintf("%s.%s", encodedKey, signature) } +func (h *AssetHandler) secret() string { + if h == nil || h.app == nil || h.app.Config() == nil { + return "" + } + return strings.TrimSpace(h.app.Config().JWT.Secret) +} + func (h *AssetHandler) parseToken(token string) (string, bool) { parts := strings.Split(token, ".") if len(parts) != 2 { diff --git a/server/internal/tenant/transport/asset_handler_test.go b/server/internal/tenant/transport/asset_handler_test.go index 1dd6043..4278a6c 100644 --- a/server/internal/tenant/transport/asset_handler_test.go +++ b/server/internal/tenant/transport/asset_handler_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) @@ -50,10 +51,9 @@ func TestAssetHandlerServeDesktop_AllowsStaleSignedTenantAsset(t *testing.T) { objectKey := "tenants/7/images/test.webp" storage := &fakeAssetObjectStorage{content: mustDecodeBase64(t, "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")} handler := &AssetHandler{ - secret: "current-secret", - app: &bootstrap.App{ObjectStorage: storage}, + app: testAssetApp(storage, "current-secret"), } - staleToken := (&AssetHandler{secret: "old-secret"}).signObjectKey(objectKey) + staleToken := testSignedAssetToken(objectKey, "old-secret") router := gin.New() router.GET("/api/desktop/content/assets/:token", func(c *gin.Context) { @@ -76,10 +76,9 @@ func TestAssetHandlerServeDesktop_RejectsOtherTenantAsset(t *testing.T) { storage := &fakeAssetObjectStorage{content: []byte("not used")} handler := &AssetHandler{ - secret: "current-secret", - app: &bootstrap.App{ObjectStorage: storage}, + app: testAssetApp(storage, "current-secret"), } - token := (&AssetHandler{secret: "old-secret"}).signObjectKey("tenants/8/images/test.webp") + token := testSignedAssetToken("tenants/8/images/test.webp", "old-secret") router := gin.New() router.GET("/api/desktop/content/assets/:token", func(c *gin.Context) { @@ -105,3 +104,15 @@ func mustDecodeBase64(t *testing.T, value string) []byte { } return data } + +func testAssetApp(storage *fakeAssetObjectStorage, secret string) *bootstrap.App { + store, err := config.NewStaticStore(&config.Config{JWT: config.JWTConfig{Secret: secret}}) + if err != nil { + panic(err) + } + return &bootstrap.App{ConfigStore: store, ObjectStorage: storage} +} + +func testSignedAssetToken(objectKey, secret string) string { + return (&AssetHandler{app: testAssetApp(nil, secret)}).signObjectKey(objectKey) +} diff --git a/server/internal/tenant/transport/brand_handler.go b/server/internal/tenant/transport/brand_handler.go index 5e2017e..aeec158 100644 --- a/server/internal/tenant/transport/brand_handler.go +++ b/server/internal/tenant/transport/brand_handler.go @@ -15,7 +15,7 @@ type BrandHandler struct { } func NewBrandHandler(a *bootstrap.App) *BrandHandler { - return &BrandHandler{svc: app.NewBrandService(a.DB, a.MonitoringDB, a.AuditLogs, a.Config.BrandLibrary).WithCache(a.Cache)} + return &BrandHandler{svc: a.BrandService} } func (h *BrandHandler) List(c *gin.Context) { diff --git a/server/internal/tenant/transport/internal_metrics_handler.go b/server/internal/tenant/transport/internal_metrics_handler.go index 8d801cc..d9db296 100644 --- a/server/internal/tenant/transport/internal_metrics_handler.go +++ b/server/internal/tenant/transport/internal_metrics_handler.go @@ -11,14 +11,14 @@ import ( ) func registerInternalMetricsRoutes(app *bootstrap.App) { - token := strings.TrimSpace(app.Config.Scheduler.InternalMetricsToken) + token := strings.TrimSpace(app.Config().Scheduler.InternalMetricsToken) if token == "" { app.Logger.Warn("tenant-api internal metrics routes not registered: scheduler.internal_metrics_token is required") return } metrics := app.Engine.Group("/api/internal/metrics") - metrics.Use(internalMetricsAuthMiddleware(token)) + metrics.Use(internalMetricsAuthMiddleware(app)) metrics.GET("/monitoring/heartbeat-primary-lease", func(c *gin.Context) { response.Success(c, tenantapp.MonitoringHeartbeatPrimaryLeaseMetricsSnapshotValue()) }) @@ -35,9 +35,12 @@ func registerInternalMetricsRoutes(app *bootstrap.App) { }) } -func internalMetricsAuthMiddleware(token string) gin.HandlerFunc { - expected := strings.TrimSpace(token) +func internalMetricsAuthMiddleware(app *bootstrap.App) gin.HandlerFunc { return func(c *gin.Context) { + expected := "" + if cfg := app.Config(); cfg != nil { + expected = strings.TrimSpace(cfg.Scheduler.InternalMetricsToken) + } if expected == "" || !internalMetricsTokenMatches(c, expected) { response.Error(c, response.ErrUnauthorized(40181, "internal_metrics_unauthorized", "valid internal metrics token is required")) c.Abort() diff --git a/server/internal/tenant/transport/knowledge_handler.go b/server/internal/tenant/transport/knowledge_handler.go index 41ffeb6..8014c79 100644 --- a/server/internal/tenant/transport/knowledge_handler.go +++ b/server/internal/tenant/transport/knowledge_handler.go @@ -25,11 +25,11 @@ func NewKnowledgeHandler(a *bootstrap.App) *KnowledgeHandler { a.ObjectStorage, a.LLM, a.Logger, - a.Config.Retrieval, - a.Config.LLM, - a.Config.Generation.QueueSize, - a.Config.Generation.WorkerConcurrency, - ), + a.Config().Retrieval, + a.Config().LLM, + a.Config().Generation.QueueSize, + a.Config().Generation.WorkerConcurrency, + ).WithConfigProvider(a.ConfigStore), } } diff --git a/server/internal/tenant/transport/kol_manage_handler.go b/server/internal/tenant/transport/kol_manage_handler.go index 0224639..b1b33bf 100644 --- a/server/internal/tenant/transport/kol_manage_handler.go +++ b/server/internal/tenant/transport/kol_manage_handler.go @@ -42,7 +42,7 @@ func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandl profileSvc: profileSvc, pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions).WithCache(a.Cache), promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset).WithCache(a.Cache), - assistSvc: app.NewKolAssistService(a.DB, profileSvc, a.KolAssists, a.LLM, a.RabbitMQ, a.Config.LLM, a.Logger).WithCache(a.Cache), + assistSvc: app.NewKolAssistService(a.DB, profileSvc, a.KolAssists, a.LLM, a.RabbitMQ, a.Config().LLM, a.Logger).WithCache(a.Cache).WithConfigProvider(a.ConfigStore), assets: assets, } } diff --git a/server/internal/tenant/transport/monitoring_handler.go b/server/internal/tenant/transport/monitoring_handler.go index b100e60..309f76a 100644 --- a/server/internal/tenant/transport/monitoring_handler.go +++ b/server/internal/tenant/transport/monitoring_handler.go @@ -26,7 +26,7 @@ type monitoringCollectNowRequest struct { func NewMonitoringHandler(a *bootstrap.App) *MonitoringHandler { return &MonitoringHandler{ - svc: app.NewMonitoringService(a.DB, a.MonitoringDB, a.RabbitMQ, a.Config.MonitoringDispatch, a.Config.BrandLibrary, a.Logger).WithRedis(a.Redis), + svc: a.MonitoringService, } } diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 5f07e81..4a6e576 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -247,7 +247,7 @@ func RegisterRoutes(app *bootstrap.App) { imageFolders.GET("/:id/delete-preview", imgHandler.FolderDeletePreview) imageFolders.DELETE("/:id", imgHandler.DeleteFolder) - if swagger.EnabledForMode(app.Config.Server.Mode) { + if swagger.EnabledForMode(app.Config().Server.Mode) { swagger.Register(app.Engine, swagger.Config{ Title: "省心推 Admin API", Version: "dev", diff --git a/server/internal/tenant/transport/template_handler.go b/server/internal/tenant/transport/template_handler.go index 20d1eaf..319317f 100644 --- a/server/internal/tenant/transport/template_handler.go +++ b/server/internal/tenant/transport/template_handler.go @@ -23,11 +23,11 @@ func NewTemplateHandler(a *bootstrap.App) *TemplateHandler { a.ObjectStorage, a.LLM, a.Logger, - a.Config.Retrieval, - a.Config.LLM, + a.Config().Retrieval, + a.Config().LLM, 0, 0, - ) + ).WithConfigProvider(a.ConfigStore) return &TemplateHandler{ svc: app.NewTemplateService( @@ -40,9 +40,9 @@ func NewTemplateHandler(a *bootstrap.App) *TemplateHandler { a.RabbitMQ, knowledgeSvc, a.GenerationStreams, - a.Config.Generation, - a.Config.LLM.MaxOutputTokens, - ).WithCache(a.Cache), + a.Config().Generation, + a.Config().LLM.MaxOutputTokens, + ).WithCache(a.Cache).WithConfigProvider(a.ConfigStore), } } diff --git a/server/internal/worker/assist/kol_assist_worker.go b/server/internal/worker/assist/kol_assist_worker.go index 1f6ff9d..0ecd5a6 100644 --- a/server/internal/worker/assist/kol_assist_worker.go +++ b/server/internal/worker/assist/kol_assist_worker.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" + "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/llm" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app" @@ -30,6 +31,7 @@ type KolAssistWorker struct { cache sharedcache.Cache retryInterval time.Duration processTimeout time.Duration + configProvider config.Provider consumerPrefix string workerConcurrency int } @@ -65,6 +67,11 @@ func NewKolAssistWorker( } } +func (w *KolAssistWorker) WithConfigProvider(provider config.Provider) *KolAssistWorker { + w.configProvider = provider + return w +} + func (w *KolAssistWorker) Start(ctx context.Context) { if w == nil || w.rabbitMQ == nil || w.repo == nil || w.llm == nil { return @@ -166,7 +173,7 @@ func (w *KolAssistWorker) handleDelivery(parent context.Context, delivery amqp.D return } - ctx, cancel := context.WithTimeout(parent, w.processTimeout) + ctx, cancel := context.WithTimeout(parent, w.runtimeProcessTimeout()) defer cancel() if err := w.Process(ctx, *job); err != nil { @@ -184,6 +191,25 @@ func (w *KolAssistWorker) handleDelivery(parent context.Context, delivery amqp.D } } +func (w *KolAssistWorker) runtimeProcessTimeout() time.Duration { + if w != nil && w.configProvider != nil { + if cfg := w.configProvider.Current(); cfg != nil { + return kolAssistProcessTimeout(cfg.Generation) + } + } + if w != nil && w.processTimeout > 0 { + return w.processTimeout + } + return kolAssistProcessTimeout(config.GenerationConfig{}) +} + +func kolAssistProcessTimeout(cfg config.GenerationConfig) time.Duration { + if cfg.ArticleTimeout > 0 { + return cfg.ArticleTimeout + } + return 5 * time.Minute +} + func (w *KolAssistWorker) rejectDelivery(delivery amqp.Delivery, requeue bool, err error, taskID string, tenantID int64) { if nackErr := delivery.Nack(false, requeue); nackErr != nil && w.logger != nil { w.logger.Warn("kol assist nack failed", diff --git a/server/internal/worker/generate/article_generation_worker.go b/server/internal/worker/generate/article_generation_worker.go index 8804964..ba6ee81 100644 --- a/server/internal/worker/generate/article_generation_worker.go +++ b/server/internal/worker/generate/article_generation_worker.go @@ -34,6 +34,7 @@ type ArticleGenerationWorker struct { logger *zap.Logger retryInterval time.Duration processTimeout time.Duration + configProvider config.Provider consumerPrefix string workerConcurrency int } @@ -53,11 +54,6 @@ func NewArticleGenerationWorker( workerConcurrency = 1 } - processTimeout := cfg.ArticleTimeout + 2*time.Minute - if processTimeout <= 2*time.Minute { - processTimeout = 10 * time.Minute - } - return &ArticleGenerationWorker{ pool: pool, rabbitMQ: rabbitMQClient, @@ -67,12 +63,17 @@ func NewArticleGenerationWorker( kolWorker: kolWorker, logger: logger, retryInterval: defaultArticleGenerationWorkerRetryInterval, - processTimeout: processTimeout, + processTimeout: articleGenerationProcessTimeout(cfg), consumerPrefix: fmt.Sprintf("worker-generate-%d", os.Getpid()), workerConcurrency: workerConcurrency, } } +func (w *ArticleGenerationWorker) WithConfigProvider(provider config.Provider) *ArticleGenerationWorker { + w.configProvider = provider + return w +} + func (w *ArticleGenerationWorker) Start(ctx context.Context) { if w == nil || w.pool == nil || w.rabbitMQ == nil { return @@ -134,7 +135,7 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver return } - ctx, cancel := context.WithTimeout(parent, w.processTimeout) + ctx, cancel := context.WithTimeout(parent, w.runtimeProcessTimeout()) defer cancel() task, terminal, err := w.loadTask(ctx, envelope.TaskID) @@ -205,6 +206,26 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver } } +func (w *ArticleGenerationWorker) runtimeProcessTimeout() time.Duration { + if w != nil && w.configProvider != nil { + if cfg := w.configProvider.Current(); cfg != nil { + return articleGenerationProcessTimeout(cfg.Generation) + } + } + if w != nil && w.processTimeout > 0 { + return w.processTimeout + } + return articleGenerationProcessTimeout(config.GenerationConfig{}) +} + +func articleGenerationProcessTimeout(cfg config.GenerationConfig) time.Duration { + processTimeout := cfg.ArticleTimeout + 2*time.Minute + if processTimeout <= 2*time.Minute { + return 10 * time.Minute + } + return processTimeout +} + func (w *ArticleGenerationWorker) handleTaskFailure(ctx context.Context, task tenantapp.ClaimedGenerationTask, stage string, taskErr error) { task.TaskType = strings.TrimSpace(task.TaskType) switch { diff --git a/server/internal/worker/generate/kol_generation_worker.go b/server/internal/worker/generate/kol_generation_worker.go index 981520a..c1bdde8 100644 --- a/server/internal/worker/generate/kol_generation_worker.go +++ b/server/internal/worker/generate/kol_generation_worker.go @@ -23,13 +23,14 @@ import ( const kolGenerationTaskType = "kol" type KolGenerationWorker struct { - pool *pgxpool.Pool - llm llm.Client - knowledge *tenantapp.KnowledgeService - logger *zap.Logger - articleTimeout time.Duration - maxOutputTokens int64 - cache sharedcache.Cache + pool *pgxpool.Pool + llm llm.Client + knowledge *tenantapp.KnowledgeService + logger *zap.Logger + cfg config.GenerationConfig + llmCfg config.LLMConfig + provider config.Provider + cache sharedcache.Cache } func NewKolGenerationWorker( @@ -40,18 +41,13 @@ func NewKolGenerationWorker( cfg config.GenerationConfig, llmMaxOutputTokens int64, ) *KolGenerationWorker { - articleTimeout := cfg.ArticleTimeout - if articleTimeout <= 0 { - articleTimeout = 8 * time.Minute - } - return &KolGenerationWorker{ - pool: pool, - llm: llmClient, - knowledge: knowledgeSvc, - logger: logger, - articleTimeout: articleTimeout, - maxOutputTokens: llmMaxOutputTokens, + pool: pool, + llm: llmClient, + knowledge: knowledgeSvc, + logger: logger, + cfg: cfg, + llmCfg: config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens}, } } @@ -60,6 +56,23 @@ func (w *KolGenerationWorker) WithCache(c sharedcache.Cache) *KolGenerationWorke return w } +func (w *KolGenerationWorker) WithConfigProvider(provider config.Provider) *KolGenerationWorker { + w.provider = provider + return w +} + +func (w *KolGenerationWorker) runtimeConfig() (time.Duration, int64) { + if w != nil && w.provider != nil { + if cfg := w.provider.Current(); cfg != nil { + return kolArticleTimeout(cfg.Generation), cfg.LLM.MaxOutputTokens + } + } + if w == nil { + return 8 * time.Minute, 0 + } + return kolArticleTimeout(w.cfg), w.llmCfg.MaxOutputTokens +} + func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.ClaimedGenerationTask) { renderedPrompt := strings.TrimSpace(kolStringInput(task.InputParams, "rendered_prompt")) if renderedPrompt == "" { @@ -114,10 +127,11 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime prompt = strings.TrimSpace(renderedPrompt + "\n\n" + knowledgePrompt) } + articleTimeout, maxOutputTokens := w.runtimeConfig() generateReq := llm.GenerateRequest{ Prompt: prompt, - Timeout: w.articleTimeout, - MaxOutputTokens: w.maxOutputTokens, + Timeout: articleTimeout, + MaxOutputTokens: maxOutputTokens, } if kolBoolInput(task.InputParams, "enable_web_search") { generateReq.WebSearch = &llm.WebSearchOptions{Enabled: true} @@ -236,6 +250,13 @@ var ( errEmptyGeneratedContent = errors.New("kol generation returned empty content") ) +func kolArticleTimeout(cfg config.GenerationConfig) time.Duration { + if cfg.ArticleTimeout > 0 { + return cfg.ArticleTimeout + } + return 8 * time.Minute +} + func kolStringInput(input map[string]interface{}, key string) string { if input == nil { return "" diff --git a/server/internal/worker/generate/template_assist_worker.go b/server/internal/worker/generate/template_assist_worker.go index 27e219d..6b0c8b7 100644 --- a/server/internal/worker/generate/template_assist_worker.go +++ b/server/internal/worker/generate/template_assist_worker.go @@ -10,6 +10,7 @@ import ( amqp "github.com/rabbitmq/amqp091-go" "go.uber.org/zap" + "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app" ) @@ -22,6 +23,7 @@ type TemplateAssistWorker struct { logger *zap.Logger retryInterval time.Duration processTimeout time.Duration + configProvider config.Provider consumerPrefix string workerConcurrency int } @@ -51,6 +53,11 @@ func NewTemplateAssistWorker( } } +func (w *TemplateAssistWorker) WithConfigProvider(provider config.Provider) *TemplateAssistWorker { + w.configProvider = provider + return w +} + func (w *TemplateAssistWorker) Start(ctx context.Context) { if w == nil || w.rabbitMQ == nil || w.templateService == nil { return @@ -112,7 +119,7 @@ func (w *TemplateAssistWorker) handleDelivery(parent context.Context, delivery a return } - ctx, cancel := context.WithTimeout(parent, w.processTimeout) + ctx, cancel := context.WithTimeout(parent, w.runtimeProcessTimeout()) defer cancel() terminal, err := w.templateService.IsAssistTaskTerminal(ctx, *job) @@ -143,6 +150,25 @@ func (w *TemplateAssistWorker) handleDelivery(parent context.Context, delivery a } } +func (w *TemplateAssistWorker) runtimeProcessTimeout() time.Duration { + if w != nil && w.configProvider != nil { + if cfg := w.configProvider.Current(); cfg != nil { + return templateAssistProcessTimeout(cfg.Generation) + } + } + if w != nil && w.processTimeout > 0 { + return w.processTimeout + } + return templateAssistProcessTimeout(config.GenerationConfig{}) +} + +func templateAssistProcessTimeout(cfg config.GenerationConfig) time.Duration { + if cfg.ArticleTimeout > 0 { + return cfg.ArticleTimeout + } + return 5 * time.Minute +} + func (w *TemplateAssistWorker) rejectDelivery(delivery amqp.Delivery, requeue bool, err error, taskID, taskType string) { if nackErr := delivery.Nack(false, requeue); nackErr != nil && w.logger != nil { w.logger.Warn("template assist nack failed",