mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
feat(core): implement cordis configuration extension and migrate all plugins
This commit is contained in:
@@ -1,259 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package config 负责应用配置的加载、解析与环境变量覆盖。
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// 默认队列优先级
|
||||
const (
|
||||
webhookQueuePriority = 10
|
||||
whitelistQueuePriority = 5
|
||||
defaultQueuePriority = 3
|
||||
)
|
||||
|
||||
// Config 全局配置单例,初始化后不可变
|
||||
var Config *configModel
|
||||
|
||||
// findConfigPath searches upward for the config file to handle tests running in subdirectories.
|
||||
func findConfigPath(configPath string) string {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
return configPath
|
||||
}
|
||||
dir := "."
|
||||
for i := 0; i < 5; i++ {
|
||||
dir += "/.."
|
||||
path := dir + "/" + configPath
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
|
||||
// isTest checks if the current execution context is within 'go test'.
|
||||
func isTest() bool {
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return true
|
||||
}
|
||||
for _, arg := range os.Args {
|
||||
if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// load reads configuration from configPath, applies defaults and environment overrides,
|
||||
// and optionally disables external services for in-test runs. It uses a private viper
|
||||
// instance so a caller can resolve the same inputs repeatedly, which the parity test
|
||||
// against the kernel configuration engine requires.
|
||||
func load(configPath string, testMode bool) *configModel {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
// 文件存在但读取/解析失败
|
||||
if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable
|
||||
log.Fatalf("[Config] read config failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
log.Println("[Config] no config file found, using environment variables only")
|
||||
v.SetConfigType("yaml")
|
||||
if err := v.ReadConfig(strings.NewReader("")); err != nil {
|
||||
log.Fatalf("[Config] failed to init empty config: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析配置到结构体
|
||||
var c configModel
|
||||
if err := v.Unmarshal(&c); err != nil {
|
||||
log.Fatalf("[Config] parse config failed: %v\n", err)
|
||||
}
|
||||
|
||||
applyDefaults(&c)
|
||||
|
||||
// 环境变量覆盖(优先级高于 config.yaml)
|
||||
applyEnvOverrides(&c)
|
||||
applyDefaults(&c)
|
||||
|
||||
// Disable standard DB/Redis initializations during tests to prevent connection attempts.
|
||||
if testMode {
|
||||
c.Database.Enabled = false
|
||||
c.Database.SQLitePath = ":memory:"
|
||||
c.Redis.Enabled = false
|
||||
c.ClickHouse.Enabled = false
|
||||
}
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 加载配置文件路径
|
||||
configPath := os.Getenv("CONFIG_PATH")
|
||||
if configPath == "" {
|
||||
configPath = findConfigPath("config.yaml")
|
||||
}
|
||||
|
||||
// 设置全局配置并打印
|
||||
Config = load(configPath, isTest())
|
||||
printConfig(Config)
|
||||
}
|
||||
|
||||
func applyDefaults(c *configModel) {
|
||||
if c.App.SessionAge <= 0 {
|
||||
c.App.SessionAge = 86400
|
||||
}
|
||||
if c.Otel.TracerName == "" {
|
||||
c.Otel.TracerName = "github.com/Rain-kl/Wavelet"
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 环境变量覆盖层 ────────────────────────────────────────────────────────────
|
||||
// 环境变量优先级高于 config.yaml,未设置则保留 yaml 中的值。
|
||||
|
||||
func envStr(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt64(key string, fallback int64) int64 {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envFloat64(key string, fallback float64) float64 {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// applyEnvOverrides 将环境变量值覆盖到配置结构体上(仅当环境变量已设置时生效)
|
||||
func applyEnvOverrides(c *configModel) {
|
||||
// ─── App ───
|
||||
c.App.AppName = envStr("APP_NAME", c.App.AppName)
|
||||
c.App.Env = envStr("APP_ENV", c.App.Env)
|
||||
c.App.Addr = envStr("APP_ADDR", c.App.Addr)
|
||||
c.App.NodeID = envInt64("APP_NODE_ID", c.App.NodeID)
|
||||
c.App.APIPrefix = envStr("APP_API_PREFIX", c.App.APIPrefix)
|
||||
c.App.GracefulShutdownTimeout = envInt("APP_GRACEFUL_SHUTDOWN_TIMEOUT", c.App.GracefulShutdownTimeout)
|
||||
c.App.SessionCookieName = envStr("APP_SESSION_COOKIE_NAME", c.App.SessionCookieName)
|
||||
c.App.SessionSecret = envStr("APP_SESSION_SECRET", c.App.SessionSecret)
|
||||
c.App.SessionDomain = envStr("APP_SESSION_DOMAIN", c.App.SessionDomain)
|
||||
c.App.SessionAge = envInt("APP_SESSION_AGE", c.App.SessionAge)
|
||||
c.App.SessionHTTPOnly = envBool("APP_SESSION_HTTP_ONLY", c.App.SessionHTTPOnly)
|
||||
c.App.SessionSecure = envBool("APP_SESSION_SECURE", c.App.SessionSecure)
|
||||
|
||||
// ─── Database ───
|
||||
c.Database.Host = envStr("DB_HOST", c.Database.Host)
|
||||
c.Database.Port = envInt("DB_PORT", c.Database.Port)
|
||||
c.Database.Username = envStr("DB_USERNAME", c.Database.Username)
|
||||
c.Database.Password = envStr("DB_PASSWORD", c.Database.Password)
|
||||
c.Database.Database = envStr("DB_NAME", c.Database.Database)
|
||||
c.Database.SSLMode = envStr("DB_SSL_MODE", c.Database.SSLMode)
|
||||
c.Database.TimeZone = envStr("DB_TIMEZONE", c.Database.TimeZone)
|
||||
c.Database.LogLevel = envStr("DB_LOG_LEVEL", c.Database.LogLevel)
|
||||
c.Database.MaxIdleConn = envInt("DB_MAX_IDLE_CONN", c.Database.MaxIdleConn)
|
||||
c.Database.MaxOpenConn = envInt("DB_MAX_OPEN_CONN", c.Database.MaxOpenConn)
|
||||
// 当 DB_HOST 环境变量已设置时自动启用数据库
|
||||
if _, ok := os.LookupEnv("DB_HOST"); ok {
|
||||
c.Database.Enabled = true
|
||||
}
|
||||
c.Database.Enabled = envBool("DB_ENABLED", c.Database.Enabled)
|
||||
c.Database.SQLitePath = envStr("SQLITE_PATH", c.Database.SQLitePath)
|
||||
|
||||
// ─── Redis ───
|
||||
if v, ok := os.LookupEnv("REDIS_ADDR"); ok {
|
||||
c.Redis.Addrs = []string{v}
|
||||
c.Redis.Enabled = true // 当 REDIS_ADDR 已设置时自动启用
|
||||
}
|
||||
c.Redis.Enabled = envBool("REDIS_ENABLED", c.Redis.Enabled)
|
||||
c.Redis.Username = envStr("REDIS_USERNAME", c.Redis.Username)
|
||||
c.Redis.Password = envStr("REDIS_PASSWORD", c.Redis.Password)
|
||||
c.Redis.DB = envInt("REDIS_DB", c.Redis.DB)
|
||||
c.Redis.KeyPrefix = envStr("REDIS_KEY_PREFIX", c.Redis.KeyPrefix)
|
||||
c.Redis.PoolSize = envInt("REDIS_POOL_SIZE", c.Redis.PoolSize)
|
||||
c.Redis.MaintNotifications = envBool("REDIS_MAINT_NOTIFICATIONS", c.Redis.MaintNotifications)
|
||||
|
||||
// ─── ClickHouse ───
|
||||
if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok {
|
||||
c.ClickHouse.Hosts = []string{v}
|
||||
c.ClickHouse.Enabled = true
|
||||
}
|
||||
c.ClickHouse.Enabled = envBool("CLICKHOUSE_ENABLED", c.ClickHouse.Enabled)
|
||||
c.ClickHouse.Username = envStr("CLICKHOUSE_USERNAME", c.ClickHouse.Username)
|
||||
c.ClickHouse.Password = envStr("CLICKHOUSE_PASSWORD", c.ClickHouse.Password)
|
||||
c.ClickHouse.Database = envStr("CLICKHOUSE_NAME", c.ClickHouse.Database)
|
||||
|
||||
// ─── Log ───
|
||||
c.Log.Level = envStr("LOG_LEVEL", c.Log.Level)
|
||||
c.Log.Format = envStr("LOG_FORMAT", c.Log.Format)
|
||||
c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output)
|
||||
|
||||
// ─── OTel ───
|
||||
c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate)
|
||||
c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName)
|
||||
|
||||
// ─── Worker ───
|
||||
c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency)
|
||||
c.Worker.StrictPriority = envBool("WORKER_STRICT_PRIORITY", c.Worker.StrictPriority)
|
||||
|
||||
// 无 yaml 且无环境变量时,使用代码级默认队列
|
||||
if len(c.Worker.Queues) == 0 {
|
||||
c.Worker.Queues = []QueueConfig{
|
||||
{Name: "webhook", Priority: webhookQueuePriority},
|
||||
{Name: "whitelist_only", Priority: whitelistQueuePriority},
|
||||
{Name: "default", Priority: defaultQueuePriority},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printConfig 打印配置内容
|
||||
func printConfig(c *configModel) {
|
||||
configJSON, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[Config] failed to marshal config: %v\n", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON))
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyEnvOverridesRedisMaintNotifications(t *testing.T) {
|
||||
t.Setenv("REDIS_MAINT_NOTIFICATIONS", "true")
|
||||
|
||||
cfg := &configModel{}
|
||||
applyEnvOverrides(cfg)
|
||||
|
||||
if !cfg.Redis.MaintNotifications {
|
||||
t.Fatal("REDIS_MAINT_NOTIFICATIONS=true was not applied")
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
type configModel struct {
|
||||
App appConfig `mapstructure:"app"`
|
||||
Database databaseConfig `mapstructure:"database"`
|
||||
Redis redisConfig `mapstructure:"redis"`
|
||||
Log logConfig `mapstructure:"log"`
|
||||
Scheduler schedulerConfig `mapstructure:"scheduler"`
|
||||
Worker workerConfig `mapstructure:"worker"`
|
||||
ClickHouse clickHouseConfig `mapstructure:"clickhouse"`
|
||||
Otel otelConfig `mapstructure:"otel"`
|
||||
}
|
||||
|
||||
// appConfig 应用基本配置
|
||||
type appConfig struct {
|
||||
AppName string `mapstructure:"app_name"`
|
||||
Env string `mapstructure:"env"`
|
||||
Addr string `mapstructure:"addr"`
|
||||
NodeID int64 `mapstructure:"node_id"`
|
||||
APIPrefix string `mapstructure:"api_prefix"`
|
||||
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
|
||||
SessionCookieName string `mapstructure:"session_cookie_name"`
|
||||
SessionSecret string `mapstructure:"session_secret"`
|
||||
SessionDomain string `mapstructure:"session_domain"`
|
||||
SessionAge int `mapstructure:"session_age"`
|
||||
SessionHTTPOnly bool `mapstructure:"session_http_only"`
|
||||
SessionSecure bool `mapstructure:"session_secure"`
|
||||
}
|
||||
|
||||
// IsProduction 检查当前环境是否为生产环境
|
||||
func (a *appConfig) IsProduction() bool {
|
||||
return a.Env == "production"
|
||||
}
|
||||
|
||||
// databaseConfig 数据库配置
|
||||
type databaseConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SQLitePath string `mapstructure:"sqlite_path"` // PostgreSQL 禁用时的 SQLite 文件路径
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
MaxIdleConn int `mapstructure:"max_idle_conn"`
|
||||
MaxOpenConn int `mapstructure:"max_open_conn"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
|
||||
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
SSLMode string `mapstructure:"ssl_mode"`
|
||||
TimeZone string `mapstructure:"time_zone"`
|
||||
ApplicationName string `mapstructure:"application_name"`
|
||||
SearchPath string `mapstructure:"search_path"`
|
||||
PreferSimpleProtocol bool `mapstructure:"prefer_simple_protocol"`
|
||||
StatementCacheCapacity int `mapstructure:"statement_cache_capacity"`
|
||||
DefaultQueryExecMode string `mapstructure:"default_query_exec_mode"`
|
||||
Replicas []databaseReplicaConfig `mapstructure:"replicas"`
|
||||
SlowThreshold time.Duration `mapstructure:"slow_threshold"`
|
||||
}
|
||||
|
||||
// databaseReplicaConfig 只读副本配置
|
||||
type databaseReplicaConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// clickhouse 配置
|
||||
type clickHouseConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Hosts []string `mapstructure:"hosts"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
MaxIdleConn int `mapstructure:"max_idle_conn"`
|
||||
MaxOpenConn int `mapstructure:"max_open_conn"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
|
||||
DialTimeout int `mapstructure:"dial_timeout"`
|
||||
BlockBufferSize uint8 `mapstructure:"block_buffer_size"`
|
||||
}
|
||||
|
||||
// redisConfig Redis配置
|
||||
type redisConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Addrs []string `mapstructure:"addrs"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
DB int `mapstructure:"db"`
|
||||
ClusterMode bool `mapstructure:"cluster_mode"`
|
||||
MasterName string `mapstructure:"master_name"`
|
||||
KeyPrefix string `mapstructure:"key_prefix"`
|
||||
PoolSize int `mapstructure:"pool_size"`
|
||||
MinIdleConn int `mapstructure:"min_idle_conn"`
|
||||
DialTimeout int `mapstructure:"dial_timeout"`
|
||||
ReadTimeout int `mapstructure:"read_timeout"`
|
||||
WriteTimeout int `mapstructure:"write_timeout"`
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
PoolTimeout int `mapstructure:"pool_timeout"`
|
||||
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
|
||||
MaintNotifications bool `mapstructure:"maint_notifications"`
|
||||
}
|
||||
|
||||
// logConfig 日志配置
|
||||
type logConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
Output string `mapstructure:"output"`
|
||||
FilePath string `mapstructure:"file_path"`
|
||||
MaxSize int `mapstructure:"max_size"`
|
||||
MaxAge int `mapstructure:"max_age"`
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
Compress bool `mapstructure:"compress"`
|
||||
}
|
||||
|
||||
// schedulerConfig 定时任务配置
|
||||
type schedulerConfig struct{}
|
||||
|
||||
// workerConfig 工作配置
|
||||
type workerConfig struct {
|
||||
Concurrency int `mapstructure:"concurrency"`
|
||||
StrictPriority bool `mapstructure:"strict_priority"`
|
||||
Queues []QueueConfig `mapstructure:"queues"`
|
||||
}
|
||||
|
||||
// QueueConfig 队列配置
|
||||
type QueueConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Priority int `mapstructure:"priority"`
|
||||
}
|
||||
|
||||
// otelConfig OpenTelemetry 配置
|
||||
type otelConfig struct {
|
||||
SamplingRate float64 `mapstructure:"sampling_rate"`
|
||||
TracerName string `mapstructure:"tracer_name"`
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Temporary migration harness: proves the kernel configuration engine resolves every
|
||||
// key identically to pkg/config. Delete this file together with backend/pkg/config in P4.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"Wavelet/core/extpoints"
|
||||
)
|
||||
|
||||
// yamlSource is a test-local ConfigSource over the repository configuration file.
|
||||
// It deliberately does not import plugins/infra/config: backend/pkg must not depend on
|
||||
// upper layers even in tests, and the adapter has its own coverage in its package tests.
|
||||
type yamlSource struct {
|
||||
v *viper.Viper
|
||||
}
|
||||
|
||||
func newYAMLSource(t *testing.T, path string) *yamlSource {
|
||||
t.Helper()
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigFile(path)
|
||||
require.NoError(t, v.ReadInConfig())
|
||||
return &yamlSource{v: v}
|
||||
}
|
||||
|
||||
func (s *yamlSource) Lookup(path string) (any, bool) {
|
||||
if !s.v.IsSet(path) {
|
||||
return nil, false
|
||||
}
|
||||
return s.v.Get(path), true
|
||||
}
|
||||
|
||||
func (s *yamlSource) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }
|
||||
|
||||
func (s *yamlSource) Describe() string { return s.v.ConfigFileUsed() }
|
||||
|
||||
// The mirror declarations below reproduce the legacy loader's coverage exactly, which is
|
||||
// why several keys declare no env: the legacy applyEnvOverrides only honoured an
|
||||
// environment variable for a subset of fields. Widening that set is a declaration-time
|
||||
// choice available to each plugin in P3, not an engine behaviour change.
|
||||
type engineAppConfig struct {
|
||||
AppName string `config:"app_name" env:"APP_NAME"`
|
||||
Env string `config:"env" env:"APP_ENV"`
|
||||
Addr string `config:"addr" env:"APP_ADDR"`
|
||||
NodeID int64 `config:"node_id" env:"APP_NODE_ID"`
|
||||
APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX"`
|
||||
GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT"`
|
||||
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME"`
|
||||
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
|
||||
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
|
||||
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
|
||||
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY"`
|
||||
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
|
||||
}
|
||||
|
||||
type engineReplicaConfig struct {
|
||||
Host string `config:"host"`
|
||||
Port int `config:"port"`
|
||||
Username string `config:"username"`
|
||||
Password string `config:"password"`
|
||||
}
|
||||
|
||||
type engineDatabaseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
|
||||
SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"`
|
||||
Host string `config:"host" env:"DB_HOST"`
|
||||
Port int `config:"port" env:"DB_PORT"`
|
||||
Username string `config:"username" env:"DB_USERNAME"`
|
||||
Password string `config:"password" env:"DB_PASSWORD" secret:"true"`
|
||||
Database string `config:"database" env:"DB_NAME"`
|
||||
MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN"`
|
||||
MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN"`
|
||||
ConnMaxLifetime int `config:"conn_max_lifetime"`
|
||||
ConnMaxIdleTime int `config:"conn_max_idle_time"`
|
||||
LogLevel string `config:"log_level" env:"DB_LOG_LEVEL"`
|
||||
SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE"`
|
||||
TimeZone string `config:"time_zone" env:"DB_TIMEZONE"`
|
||||
ApplicationName string `config:"application_name"`
|
||||
SearchPath string `config:"search_path"`
|
||||
PreferSimpleProtocol bool `config:"prefer_simple_protocol"`
|
||||
StatementCacheCapacity int `config:"statement_cache_capacity"`
|
||||
DefaultQueryExecMode string `config:"default_query_exec_mode"`
|
||||
Replicas []engineReplicaConfig `config:"replicas"`
|
||||
SlowThreshold time.Duration `config:"slow_threshold"`
|
||||
}
|
||||
|
||||
type engineRedisConfig struct {
|
||||
Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
|
||||
Addrs []string `config:"addrs" env:"REDIS_ADDR"`
|
||||
Username string `config:"username" env:"REDIS_USERNAME"`
|
||||
Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"`
|
||||
DB int `config:"db" env:"REDIS_DB"`
|
||||
ClusterMode bool `config:"cluster_mode"`
|
||||
MasterName string `config:"master_name"`
|
||||
KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
|
||||
PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"`
|
||||
MinIdleConn int `config:"min_idle_conn"`
|
||||
DialTimeout int `config:"dial_timeout"`
|
||||
ReadTimeout int `config:"read_timeout"`
|
||||
WriteTimeout int `config:"write_timeout"`
|
||||
MaxRetries int `config:"max_retries"`
|
||||
PoolTimeout int `config:"pool_timeout"`
|
||||
ConnMaxIdleTime int `config:"conn_max_idle_time"`
|
||||
MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS"`
|
||||
}
|
||||
|
||||
type engineClickHouseConfig struct {
|
||||
Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
|
||||
Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"`
|
||||
Username string `config:"username" env:"CLICKHOUSE_USERNAME"`
|
||||
Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"`
|
||||
Database string `config:"database" env:"CLICKHOUSE_NAME"`
|
||||
MaxIdleConn int `config:"max_idle_conn"`
|
||||
MaxOpenConn int `config:"max_open_conn"`
|
||||
ConnMaxLifetime int `config:"conn_max_lifetime"`
|
||||
DialTimeout int `config:"dial_timeout"`
|
||||
BlockBufferSize uint8 `config:"block_buffer_size"`
|
||||
}
|
||||
|
||||
type engineLogConfig struct {
|
||||
Level string `config:"level" env:"LOG_LEVEL"`
|
||||
Format string `config:"format" env:"LOG_FORMAT"`
|
||||
Output string `config:"output" env:"LOG_OUTPUT"`
|
||||
FilePath string `config:"file_path"`
|
||||
MaxSize int `config:"max_size"`
|
||||
MaxAge int `config:"max_age"`
|
||||
MaxBackups int `config:"max_backups"`
|
||||
Compress bool `config:"compress"`
|
||||
}
|
||||
|
||||
type engineOtelConfig struct {
|
||||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
|
||||
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
|
||||
}
|
||||
|
||||
type engineQueueConfig struct {
|
||||
Name string `config:"name"`
|
||||
Priority int `config:"priority"`
|
||||
}
|
||||
|
||||
type engineWorkerConfig struct {
|
||||
Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"`
|
||||
StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"`
|
||||
Queues []engineQueueConfig `config:"queues"`
|
||||
}
|
||||
|
||||
// durationType mirrors the engine's own notion of a scalar duration field.
|
||||
var durationType = reflect.TypeFor[time.Duration]()
|
||||
|
||||
// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the
|
||||
// parity assertion use distinct Go types for the same shape, so values are compared
|
||||
// textually instead of handing cmp a cross-type diff.
|
||||
func flatten(prefix string, v reflect.Value, out map[string]string) {
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fv := v.Field(i)
|
||||
path := prefix + "." + field.Name
|
||||
if fv.Kind() == reflect.Struct && fv.Type() != durationType {
|
||||
flatten(path, fv, out)
|
||||
continue
|
||||
}
|
||||
out[path] = fmt.Sprint(fv.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
// configCandidates returns the configuration files to run parity against. The tracked
|
||||
// example file is mandatory so the proof never skips silently in a fresh clone. A local
|
||||
// gitignored config.yaml is compared as an extra scenario only when it sits next to the
|
||||
// example file: the upward search used to locate it can otherwise leave the current
|
||||
// checkout and silently read a sibling worktree's configuration instead.
|
||||
func configCandidates(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
example := findConfigPath("config.example.yaml")
|
||||
info, err := os.Stat(example)
|
||||
if err != nil || info.IsDir() {
|
||||
t.Fatalf("tracked config.example.yaml is unreachable from the test working directory: %v", err)
|
||||
}
|
||||
|
||||
candidates := []string{example}
|
||||
local := filepath.Join(filepath.Dir(example), "config.yaml")
|
||||
if localInfo, localErr := os.Stat(local); localErr == nil && !localInfo.IsDir() {
|
||||
candidates = append(candidates, local)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// bindSections declares every legacy section against the engine and binds them out.
|
||||
func bindSections(t *testing.T, path string) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
engine := extpoints.NewConfigRegistry(newYAMLSource(t, path))
|
||||
require.NoError(t, engine.Declare("parity",
|
||||
extpoints.ConfigBinding{Prefix: "app", Target: &engineAppConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "database", Target: &engineDatabaseConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "redis", Target: &engineRedisConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}},
|
||||
extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}},
|
||||
))
|
||||
require.NoError(t, engine.Resolve())
|
||||
|
||||
var (
|
||||
app engineAppConfig
|
||||
database engineDatabaseConfig
|
||||
redis engineRedisConfig
|
||||
clickhouse engineClickHouseConfig
|
||||
log engineLogConfig
|
||||
otel engineOtelConfig
|
||||
worker engineWorkerConfig
|
||||
)
|
||||
targets := []struct {
|
||||
prefix string
|
||||
target any
|
||||
}{
|
||||
{"app", &app}, {"database", &database}, {"redis", &redis}, {"clickhouse", &clickhouse},
|
||||
{"log", &log}, {"otel", &otel}, {"worker", &worker},
|
||||
}
|
||||
for _, item := range targets {
|
||||
require.NoError(t, engine.Bind(item.prefix, item.target))
|
||||
}
|
||||
|
||||
flat := map[string]string{}
|
||||
flatten("app", reflect.ValueOf(app), flat)
|
||||
flatten("database", reflect.ValueOf(database), flat)
|
||||
flatten("redis", reflect.ValueOf(redis), flat)
|
||||
flatten("clickhouse", reflect.ValueOf(clickhouse), flat)
|
||||
flatten("log", reflect.ValueOf(log), flat)
|
||||
flatten("otel", reflect.ValueOf(otel), flat)
|
||||
flatten("worker", reflect.ValueOf(worker), flat)
|
||||
return flat
|
||||
}
|
||||
|
||||
// legacySections resolves the same inputs with the legacy loader and flattens them.
|
||||
func legacySections(t *testing.T, path string) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
legacy := load(path, false)
|
||||
|
||||
flat := map[string]string{}
|
||||
flatten("app", reflect.ValueOf(legacy.App), flat)
|
||||
flatten("database", reflect.ValueOf(legacy.Database), flat)
|
||||
flatten("redis", reflect.ValueOf(legacy.Redis), flat)
|
||||
flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), flat)
|
||||
flatten("log", reflect.ValueOf(legacy.Log), flat)
|
||||
flatten("otel", reflect.ValueOf(legacy.Otel), flat)
|
||||
flatten("worker", reflect.ValueOf(legacy.Worker), flat)
|
||||
return flat
|
||||
}
|
||||
|
||||
func TestEngineParityWithLegacyLoader(t *testing.T) {
|
||||
paths := configCandidates(t)
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
}{
|
||||
{name: "file only", env: nil},
|
||||
{
|
||||
name: "implicit enable from hosts",
|
||||
env: map[string]string{
|
||||
"DB_HOST": "postgres", "REDIS_ADDR": "redis:6379", "CLICKHOUSE_HOST": "ch:9000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit flags win over implicit enable",
|
||||
env: map[string]string{
|
||||
"DB_HOST": "postgres", "DB_ENABLED": "false",
|
||||
"REDIS_ADDR": "redis:6379", "REDIS_ENABLED": "false",
|
||||
"CLICKHOUSE_HOST": "ch:9000", "CLICKHOUSE_ENABLED": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scalar overrides",
|
||||
env: map[string]string{
|
||||
"LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_PORT": "6543",
|
||||
"SQLITE_PATH": "./data/parity.db", "REDIS_KEY_PREFIX": "parity:",
|
||||
"REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5",
|
||||
"WORKER_CONCURRENCY": "7", "APP_NODE_ID": "42",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(filepath.Base(path)+" "+scenario.name, func(t *testing.T) {
|
||||
for name, value := range scenario.env {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
|
||||
assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path)),
|
||||
"engine resolution drifted from the legacy loader")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineHonoursEnvOnlyForDeclaredKeys(t *testing.T) {
|
||||
// slow_threshold has no environment counterpart in either the legacy loader or this
|
||||
// mirror declaration, so setting one must leave the file value untouched.
|
||||
t.Setenv("DB_SLOW_THRESHOLD", "9s")
|
||||
|
||||
for _, path := range configCandidates(t) {
|
||||
assert.Empty(t, cmp.Diff(legacySections(t, path), bindSections(t, path)))
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
package idgen
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/config"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
)
|
||||
@@ -17,25 +18,44 @@ const epoch int64 = 1764547200000
|
||||
|
||||
const maxNegativeIDRetries = 3
|
||||
|
||||
var node *snowflake.Node
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
node *snowflake.Node
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Init initializes the snowflake ID generator with the given node ID.
|
||||
func Init(nodeID int64) error {
|
||||
snowflake.Epoch = epoch
|
||||
|
||||
nodeID := config.Config.App.NodeID
|
||||
var err error
|
||||
node, err = snowflake.NewNode(nodeID)
|
||||
n, err := snowflake.NewNode(nodeID)
|
||||
if err != nil {
|
||||
log.Fatalf("[Snowflake] init failed: %v\n", err)
|
||||
return fmt.Errorf("idgen: init node %d failed: %w", nodeID, err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
node = n
|
||||
mu.Unlock()
|
||||
|
||||
log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNotInitialized indicates NextUint64ID was called before Init.
|
||||
var ErrNotInitialized = errors.New("idgen: Init must be called before generating IDs")
|
||||
|
||||
// NextUint64ID 生成下一个分布式唯一 ID。
|
||||
// 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。
|
||||
func NextUint64ID() uint64 {
|
||||
mu.RLock()
|
||||
n := node
|
||||
mu.RUnlock()
|
||||
|
||||
if n == nil {
|
||||
panic(ErrNotInitialized)
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ {
|
||||
id := node.Generate().Int64()
|
||||
id := n.Generate().Int64()
|
||||
if id >= 0 {
|
||||
return uint64(id)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,24 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNextUint64ID(t *testing.T) {
|
||||
require.NoError(t, Init(1))
|
||||
id := NextUint64ID()
|
||||
assert.NotZero(t, id)
|
||||
}
|
||||
|
||||
func TestNextUint64ID_PanicsWhenNotInitialized(t *testing.T) {
|
||||
mu.Lock()
|
||||
node = nil
|
||||
mu.Unlock()
|
||||
|
||||
assert.PanicsWithError(t, ErrNotInitialized.Error(), func() {
|
||||
NextUint64ID()
|
||||
})
|
||||
|
||||
// Restore initialization for other tests
|
||||
require.NoError(t, Init(1))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user