fix(redis): add maintenance notification startup switch

Default Redis maintenance notification negotiation to disabled and apply the startup-only setting to both platform and Asynq clients.
This commit is contained in:
ryan
2026-07-13 15:43:15 +08:00
parent 60bc03f519
commit 0548a8a5d4
11 changed files with 240 additions and 64 deletions
+2
View File
@@ -49,6 +49,8 @@ REDIS_ADDR=redis:6379
# REDIS_DB=0
REDIS_KEY_PREFIX=openflare:
# REDIS_POOL_SIZE=100
# 启动时开关;修改后需重启服务
REDIS_MAINT_NOTIFICATIONS=false
# compose 宿主机映射端口(仅 docker-compose 使用)
# REDIS_PORT=6379
+1
View File
@@ -67,6 +67,7 @@ redis:
max_retries: 3
pool_timeout: 4
conn_max_idle_time: 300
maint_notifications: false # 启动时开关;启用 Redis maintenance notifications 自动协商,修改后需重启
# ─── Logging ────────────────────────────────────────────────────────────────────
log:
+1
View File
@@ -36,6 +36,7 @@ sidebar: false
### 修复
- GORM SQL 语句改为仅在全局 `debug` 日志级别输出;生产默认日志级别不再记录查询文本,慢查询与执行错误也不包含 SQL 参数,避免节点访问令牌等敏感信息写入应用日志。
- 新增 `redis.maint_notifications` 启动开关并默认关闭,平台与 Asynq Redis 客户端仅在显式开启时进行 maintenance notifications 自动协商,避免不支持该命令的 Redis 服务持续输出握手 fallback 警告。
- WAF 规则编辑器支持通过按钮或键盘删除普通节点和连线,节点属性栏改为选中节点后按需显示,并修复拖动节点时因 React Flow 初始化状态丢失导致的画面闪烁与 error 015。
- WAF 规则编辑器新增启用/停用控制,并移除已废弃的规则组侧站点绑定入口;站点规则顺序统一在反代路由详情中管理。
- 修复 Agent 心跳无法从活动 WAF 运行图发现 IP 组引用的问题,并对发布与同步的完整 IP 组快照增加 20 MiB 聚合容量保护。
+1
View File
@@ -97,6 +97,7 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| `redis.db` | `REDIS_DB` | Redis 逻辑数据库编号 | `0` |
| `redis.key_prefix` | `REDIS_KEY_PREFIX` | 系统在 Redis 中使用的键前缀 | `openflare:` |
| `redis.pool_size` | `REDIS_POOL_SIZE` | Redis 连接池大小 | `100` |
| `redis.maint_notifications` | `REDIS_MAINT_NOTIFICATIONS` | 启动时是否启用 Redis maintenance notifications 自动协商;兼容性不明时保持关闭,修改后需重启 | `false` |
### 4. ClickHouse 配置 (`clickhouse:`)
+1
View File
@@ -253,6 +253,7 @@ func applyEnvOverrides(c *configModel) {
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 {
+14
View File
@@ -0,0 +1,14 @@
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")
}
}
+17 -16
View File
@@ -88,22 +88,23 @@ type clickHouseConfig struct {
// 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"`
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 日志配置
+41 -32
View File
@@ -35,45 +35,46 @@ func init() {
if cfg.ClusterMode {
// Cluster 模式
Redis = redis.NewClusterClient(&redis.ClusterOptions{
Addrs: cfg.Addrs,
Username: cfg.Username,
Password: cfg.Password,
PoolSize: cfg.PoolSize,
MinIdleConns: cfg.MinIdleConn,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
MaxRetries: cfg.MaxRetries,
PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second,
ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second,
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
Addrs: cfg.Addrs,
Username: cfg.Username,
Password: cfg.Password,
PoolSize: cfg.PoolSize,
MinIdleConns: cfg.MinIdleConn,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
MaxRetries: cfg.MaxRetries,
PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second,
ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second,
MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications),
})
log.Println("[Redis] initialized in Cluster mode")
} else {
// Standalone 或 Sentinel 模式
Redis = redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: cfg.Addrs,
MasterName: cfg.MasterName, // 非空时启用 Sentinel
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
MinIdleConns: cfg.MinIdleConn,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
MaxRetries: cfg.MaxRetries,
PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second,
ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second,
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
options := &redis.UniversalOptions{
Addrs: cfg.Addrs,
MasterName: cfg.MasterName, // 非空时启用 Sentinel
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
MinIdleConns: cfg.MinIdleConn,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
MaxRetries: cfg.MaxRetries,
PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second,
ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second,
MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications),
}
if cfg.MasterName != "" {
client := redis.NewFailoverClient(options.Failover())
// FailoverOptions 暂不暴露该配置,在首次建连前写入客户端选项。
client.Options().MaintNotificationsConfig = redisMaintNotificationsConfig(cfg.MaintNotifications)
Redis = client
log.Println("[Redis] initialized in Sentinel mode")
} else {
Redis = redis.NewUniversalClient(options)
log.Println("[Redis] initialized in Standalone mode")
}
}
@@ -97,6 +98,14 @@ func init() {
}
}
func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config {
mode := maintnotifications.ModeDisabled
if enabled {
mode = maintnotifications.ModeAuto
}
return &maintnotifications.Config{Mode: mode}
}
// PrefixedKey 返回带前缀的 Key
func PrefixedKey(key string) string {
prefix := config.Config.Redis.KeyPrefix
+25
View File
@@ -0,0 +1,25 @@
package db
import (
"testing"
"github.com/redis/go-redis/v9/maintnotifications"
)
func TestRedisMaintNotificationsConfig(t *testing.T) {
for _, test := range []struct {
name string
enabled bool
want maintnotifications.Mode
}{
{name: "disabled by default", enabled: false, want: maintnotifications.ModeDisabled},
{name: "auto when enabled", enabled: true, want: maintnotifications.ModeAuto},
} {
t.Run(test.name, func(t *testing.T) {
cfg := redisMaintNotificationsConfig(test.enabled)
if cfg.Mode != test.want {
t.Fatalf("maintenance notifications mode = %v, want %v", cfg.Mode, test.want)
}
})
}
}
+64 -16
View File
@@ -7,8 +7,47 @@ package task
import (
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
)
type redisClientConnOpt struct {
options redis.Options
}
func (opt redisClientConnOpt) MakeRedisClient() interface{} {
return redis.NewClient(&opt.options)
}
type redisClusterConnOpt struct {
options redis.ClusterOptions
}
func (opt redisClusterConnOpt) MakeRedisClient() interface{} {
return redis.NewClusterClient(&opt.options)
}
type redisFailoverConnOpt struct {
options redis.FailoverOptions
maintNotificationsEnabled bool
}
func (opt redisFailoverConnOpt) MakeRedisClient() interface{} {
client := redis.NewFailoverClient(&opt.options)
// go-redis v9.16 does not expose maintenance notification settings on
// FailoverOptions, so apply the configured mode before the client is used.
client.Options().MaintNotificationsConfig = maintNotificationsConfig(opt.maintNotificationsEnabled)
return client
}
func maintNotificationsConfig(enabled bool) *maintnotifications.Config {
mode := maintnotifications.ModeDisabled
if enabled {
mode = maintnotifications.ModeAuto
}
return &maintnotifications.Config{Mode: mode}
}
// RedisOpt asynq Redis 连接配置(兼容 Standalone/Sentinel/Cluster)
var RedisOpt asynq.RedisConnOpt
@@ -26,20 +65,26 @@ func NewRedisConnOpt() asynq.RedisConnOpt {
addrs := cfg.Addrs
if cfg.ClusterMode {
return asynq.RedisClusterClientOpt{
Addrs: addrs,
Username: cfg.Username,
Password: cfg.Password,
return redisClusterConnOpt{
options: redis.ClusterOptions{
Addrs: addrs,
Username: cfg.Username,
Password: cfg.Password,
MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications),
},
}
}
if cfg.MasterName != "" {
return asynq.RedisFailoverClientOpt{
MasterName: cfg.MasterName,
SentinelAddrs: addrs,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
return redisFailoverConnOpt{
maintNotificationsEnabled: cfg.MaintNotifications,
options: redis.FailoverOptions{
MasterName: cfg.MasterName,
SentinelAddrs: addrs,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
},
}
}
@@ -47,12 +92,15 @@ func NewRedisConnOpt() asynq.RedisConnOpt {
if len(addrs) > 0 {
addr = addrs[0]
}
return asynq.RedisClientOpt{
Addr: addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
return redisClientConnOpt{
options: redis.Options{
Addr: addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications),
},
}
}
+73
View File
@@ -0,0 +1,73 @@
package task
import (
"testing"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
)
func TestNewRedisConnOptConfiguresMaintenanceNotifications(t *testing.T) {
previous := config.Config.Redis
t.Cleanup(func() { config.Config.Redis = previous })
for _, test := range []struct {
name string
enabled bool
want maintnotifications.Mode
}{
{name: "disabled by default", enabled: false, want: maintnotifications.ModeDisabled},
{name: "auto when enabled", enabled: true, want: maintnotifications.ModeAuto},
} {
t.Run(test.name, func(t *testing.T) {
config.Config.Redis.MaintNotifications = test.enabled
t.Run("standalone", func(t *testing.T) {
config.Config.Redis.ClusterMode = false
config.Config.Redis.MasterName = ""
config.Config.Redis.Addrs = []string{"127.0.0.1:6379"}
client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client)
if !ok {
t.Fatal("standalone option did not create *redis.Client")
}
defer func() { _ = client.Close() }()
assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want)
})
t.Run("cluster", func(t *testing.T) {
config.Config.Redis.ClusterMode = true
config.Config.Redis.MasterName = ""
config.Config.Redis.Addrs = []string{"127.0.0.1:6379"}
client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.ClusterClient)
if !ok {
t.Fatal("cluster option did not create *redis.ClusterClient")
}
defer func() { _ = client.Close() }()
assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want)
})
t.Run("sentinel", func(t *testing.T) {
config.Config.Redis.ClusterMode = false
config.Config.Redis.MasterName = "openflare"
config.Config.Redis.Addrs = []string{"127.0.0.1:26379"}
client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client)
if !ok {
t.Fatal("sentinel option did not create *redis.Client")
}
defer func() { _ = client.Close() }()
assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want)
})
})
}
}
func assertMaintenanceNotificationsMode(t *testing.T, cfg *maintnotifications.Config, want maintnotifications.Mode) {
t.Helper()
if cfg == nil || cfg.Mode != want {
t.Fatalf("maintenance notifications mode = %v, want %v", cfg, want)
}
}