test(database): H-db-1 补齐行为闭环 + 修复 gorm.Open automatic ping
上一提交(72ea01d)修了 4 条 pingWithTimeout 路径,但行为验证有缺口: 启动 ping 路径仅代码审查、Redis HealthCheck 仅推断、无 App 级端到端。 补齐过程发现并修复了 H-db-1 的真正根因之一: gorm.Open 的 automatic ping(gorm.go:204,!DisableAutomaticPing 且 ConnPool 为 *sql.DB 时调 pinger.Ping() 无超时)在 pingWithTimeout 之前就无限阻塞。 挂起 DB 下 initDB 的 gorm.Open 永久 hang,根本到不了 pingWithTimeout。 修复:initDB/InitDBWithReplicas 的 gormConfig 加 DisableAutomaticPing: true, 框架用 pingWithTimeout(3s)自管启动 ping。 行为闭环测试(3 缺口补齐): - manager_hdb1_internal_test.go:新增 TestInitDBBoundsHungDB_Hdb1 / TestInitDBWithReplicasBoundsHungDB_Hdb1(hungDialector 注入挂起 *sql.DB, 回归启动 master/replica ping 路径有界)。原 3 用例保留。 - redis_hdb1_internal_test.go:startHungRedisListener(accept 不响应模拟挂起 Redis)+ TestRedisHealthCheckBoundsHungRedis_Hdb1,行为验证 HealthCheck 受 client ReadTimeout 3s 约束(不再是推断)。提取 newRedisClient helper 供 Init 与测试共用。 - app_hdb1_test.go:TestAppInitHungDBBounded_Hdb1 App 级端到端--App.Init 挂起 DB 有界失败(~30s 5 次重试,非无限 hang)+ Shutdown 正常退出。 验证:database -race -short green(24.7s,6 个 H-db-1 用例)、root 包 App -race green(33.5s,含端到端)、build/vet 干净。CHANGELOG 更新。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -63,7 +63,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **config `watchLoop` 增加 ctx 逃生通道**(L-config-2):原仅靠 `w.Events` 关闭退出,与"for 消费循环须 ctx.Done"红线有张力。`StopWatcher` 改为 cancel ctx + Close watcher 双重退出。
|
||||
- **config `Validate` 连接池交叉校验**(L-config-3):`MaxOpenConns>0` 时 `MaxIdleConns>MaxOpenConns` 视为配置错误。
|
||||
- **config 哨兵错误改用 `errors.New`**(L-config-4);**`DSN()` 去除冗余 TrimSpace**(L-config-5);**`DSN/MySQLDSN/PostgresDSN/Addr` nil receiver 防御**(L-config-6)。
|
||||
- **database 后台探活/启动 ping 经 pingWithTimeout 3s 约束**(H-db-1,M11 修复不完整):`pingWithTimeout` 原只加到包级 `HealthCheck()`,未覆盖方法 `(*Manager).HealthCheck`(被后台探活 `probeOnce` master 与 `/health` 端点共用)、`probeOnce` 从库 ping、`InitDB`/`InitDBWithReplicas` 启动 ping。挂起 DB(连接活但不响应)下这些路径的 `PingContext` 无 ctx deadline 无限阻塞,致探活 goroutine 阻塞、#21 自愈冻结、`IsHealthy()` 缓存失真、启动卡死。现 4 路径统一经 `pingWithTimeout`(`healthCheckTimeout`=3s,尊重 ctx 自带更短 deadline)。新增 `manager_hdb1_internal_test.go` 用挂起驱动回归 3 条路径。
|
||||
- **database 后台探活/启动 ping 经 pingWithTimeout 3s 约束**(H-db-1,M11 修复不完整):`pingWithTimeout` 原只加到包级 `HealthCheck()`,未覆盖方法 `(*Manager).HealthCheck`(被后台探活 `probeOnce` master 与 `/health` 端点共用)、`probeOnce` 从库 ping、`InitDB`/`InitDBWithReplicas` 启动 ping。挂起 DB(连接活但不响应)下这些路径的 `PingContext` 无 ctx deadline 无限阻塞,致探活 goroutine 阻塞、#21 自愈冻结、`IsHealthy()` 缓存失真、启动卡死。现 4 路径统一经 `pingWithTimeout`(`healthCheckTimeout`=3s,尊重 ctx 自带更短 deadline)。**另发现并修复 gorm.Open 的 automatic ping**(gorm.go:204,ConnPool 为 `*sql.DB` 时调 `pinger.Ping()` 无超时)在 pingWithTimeout 之前就无限阻塞--`initDB`/`InitDBWithReplicas` 的 gormConfig 加 `DisableAutomaticPing: true`,框架用 pingWithTimeout 自管启动 ping。Redis 侧 `HealthCheck` 经 client `ReadTimeout`(3s) 约束(行为验证)。新增 `manager_hdb1_internal_test.go`(hungDriver 回归 pingWithTimeout/m.HealthCheck/probeOnce replica/initDB/InitDBWithReplicas 5 路径)、`redis_hdb1_internal_test.go`(挂起 Redis 回归 HealthCheck)、`app_hdb1_test.go`(App 级端到端:Init 挂起 DB 有界失败 + Shutdown 正常)。
|
||||
|
||||
- **M9 JWT issuer / refresh expiry 契约修复**:`ParseToken`、`InvalidateToken`、`GetClaimsFromToken` 统一按当前配置校验 issuer;`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键;新增 `ParseTokenFailClosed` / `ParseTokenWithBlacklistPolicy` / `TokenBlacklist.IsBlacklistedE`,默认 `ParseToken` 仍保持黑名单检查 fail-open 兼容语义,安全敏感路由可显式选择 fail-closed;解析侧配置错误现在可通过 `errors.Is` 区分 `ErrEmptySecret` / `ErrUnsupportedAlgorithm`;`InvalidateToken` 使用不校验时序的解析路径,允许提前吊销 `nbf` 在未来的外部 token。
|
||||
- **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`;`WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package xlgo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// appHungDriver 是测试用 database/sql 驱动,Conn.Ping 阻塞到 ctx 取消,模拟挂起 DB。
|
||||
// 与 database 包内部的 hungDriver 同构(app_test 为外部包,需自带一份)。
|
||||
type appHungDriver struct{}
|
||||
|
||||
func (appHungDriver) Open(string) (driver.Conn, error) { return appHungConn{}, nil }
|
||||
|
||||
type appHungConn struct{}
|
||||
|
||||
func (appHungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
|
||||
func (appHungConn) Close() error { return nil }
|
||||
func (appHungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
|
||||
|
||||
// Ping 阻塞到 ctx 取消(实现 driver.Pinger,方法名 Ping 而非 PingContext)。
|
||||
func (appHungConn) Ping(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var appHungRegisterOnce sync.Once
|
||||
|
||||
func registerAppHungDialect() {
|
||||
appHungRegisterOnce.Do(func() {
|
||||
sql.Register("xlgo_app_hung_hdb1", appHungDriver{})
|
||||
database.RegisterDialect(database.DialectSpec{
|
||||
Name: "app_hung_hdb1",
|
||||
Dialector: func(string) gorm.Dialector {
|
||||
db, _ := sql.Open("xlgo_app_hung_hdb1", "")
|
||||
return appHungDialector{sqlDB: db}
|
||||
},
|
||||
DSN: func(*config.DatabaseConfig) string { return "app_hung://" },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// appHungDialector 让 gorm.Open 成功并注入挂起 *sql.DB,使 initDB 的 pingWithTimeout 触发。
|
||||
type appHungDialector struct{ sqlDB *sql.DB }
|
||||
|
||||
func (d appHungDialector) Name() string { return "app_hung_hdb1" }
|
||||
func (d appHungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
|
||||
func (d appHungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
func (d appHungDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (d appHungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (d appHungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
func (d appHungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
|
||||
func (d appHungDialector) Explain(s string, _ ...any) string { return s }
|
||||
|
||||
// TestAppInitHungDBBounded_Hdb1 端到端回归 H-db-1(App 级闭环):
|
||||
// App.Init 启用 MySQL 且 DB 挂起时,initDB 的 pingWithTimeout 3s 失败 -> InitDB 返回错误
|
||||
// -> App.Init 失败(failAfterInit 回滚)。全程有界,不因挂起 DB 无限卡死 App.Init。
|
||||
// 修复前 gorm.Open 的 automatic Ping() 在 pingWithTimeout 之前就无限阻塞 -> App.Init 永久 hang。
|
||||
// 同时验证 App.Init 失败后无 goroutine 泄漏(closeResources 正常)。
|
||||
func TestAppInitHungDBBounded_Hdb1(t *testing.T) {
|
||||
registerAppHungDialect()
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{Env: "test"},
|
||||
Server: config.ServerConfig{Port: 18091},
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: "app_hung_hdb1",
|
||||
Host: "localhost", Port: 3306, User: "u", Password: "p", Name: "n",
|
||||
},
|
||||
}
|
||||
app := xlgo.New(xlgo.WithConfig(cfg), xlgo.WithMySQL())
|
||||
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() { done <- app.Init() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起 DB 下 App.Init 应失败,got nil")
|
||||
}
|
||||
// initDB pingWithTimeout 3s 失败后重试 5 次(每次 ping 3s + retryDelay 1s 起步翻倍),
|
||||
// 全跑约 5*4s=20s+。放宽到 35s 上限。关键是不触发下方 45s watchdog 的"无限阻塞"。
|
||||
if elapsed > 35*time.Second {
|
||||
t.Fatalf("App.Init 耗时 %v 超过 35s 上限(H-db-1:initDB ping 应被 3s 约束,重试 5 次应 ~20s)", elapsed)
|
||||
}
|
||||
case <-time.After(45 * time.Second):
|
||||
t.Fatalf("App.Init 在挂起 DB 上无限阻塞(H-db-1:gorm.Open automatic ping 或 initDB ping 未被超时约束)")
|
||||
}
|
||||
|
||||
// App.Init 失败后应能正常 Shutdown(failAfterInit 已回滚资源,Shutdown 幂等)。
|
||||
shutdownDone := make(chan error, 1)
|
||||
go func() { shutdownDone <- app.Shutdown() }()
|
||||
select {
|
||||
case err := <-shutdownDone:
|
||||
if err != nil {
|
||||
t.Logf("App.Shutdown after failed Init returned err: %v(可接受,资源已回滚)", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("App.Shutdown 在 Init 失败后无限阻塞(closeResources 未正常收口)")
|
||||
}
|
||||
|
||||
// rootCtx 已被 failAfterInit cancel,无残留 goroutine(StartProbing 未启动因 Init 失败)。
|
||||
// 此处不直接断言 goroutine 数(受测试 runtime 噪声影响),靠 Shutdown 有界退出间接证明。
|
||||
_ = context.Background()
|
||||
}
|
||||
@@ -496,6 +496,11 @@ func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
// H-db-1:禁用 gorm.Open 的自动 Ping(gorm.go:204,ConnPool 为 *sql.DB 时会调
|
||||
// pinger.Ping() 无超时)。挂起 DB(连接活但不响应)下该 Ping 无 ctx deadline 无限阻塞,
|
||||
// 发生在 initDB 的 pingWithTimeout 之前,使启动卡死。框架改用 pingWithTimeout(3s)自管
|
||||
// 启动 ping,故禁用 gorm 无超时自动 ping。InitDBWithReplicas 的 replica 路径同理。
|
||||
DisableAutomaticPing: true,
|
||||
}
|
||||
|
||||
// M-config-2:MySQL 启用 TLS 且配置自定义 CA 时,注册命名 TLS 配置,使 DSN 中 tls=<name> 生效。
|
||||
@@ -655,6 +660,8 @@ func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, re
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
// H-db-1:禁用 gorm.Open 自动 Ping(同 initDB),框架用 pingWithTimeout 自管 replica 启动 ping。
|
||||
DisableAutomaticPing: true,
|
||||
}
|
||||
|
||||
// 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态
|
||||
|
||||
@@ -10,7 +10,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// hungDriver 是测试用 database/sql 驱动,其 Conn.PingContext 阻塞直到 ctx 取消,
|
||||
@@ -126,3 +129,71 @@ func TestProbeOnceReplicaBoundsHungDB_Hdb1(t *testing.T) {
|
||||
t.Errorf("挂起从库应被标记不健康(replicaHealthy=false),got true")
|
||||
}
|
||||
}
|
||||
|
||||
// hungDialector 让 gorm.Open 成功并注入挂起 *sql.DB 作为 ConnPool,使 initDB 的
|
||||
// db.DB() 返回挂起 *sql.DB、pingWithTimeout 触发。用于回归启动 ping 路径。
|
||||
type hungDialector struct{ sqlDB *sql.DB }
|
||||
|
||||
func (d hungDialector) Name() string { return "hdb1_hung" }
|
||||
func (d hungDialector) Initialize(db *gorm.DB) error { db.Config.ConnPool = d.sqlDB; return nil }
|
||||
func (d hungDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
func (d hungDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
func (d hungDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
func (d hungDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
func (d hungDialector) QuoteTo(w clause.Writer, s string) { _, _ = w.WriteString(s) }
|
||||
func (d hungDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
// registerHungDialect 注册挂起 dialect(幂等)。
|
||||
func registerHungDialect(t *testing.T) {
|
||||
t.Helper()
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: "hdb1_hung",
|
||||
Dialector: func(string) gorm.Dialector { return hungDialector{sqlDB: newHungSqlDB(t)} },
|
||||
DSN: func(*config.DatabaseConfig) string { return "hdb1_hung://" },
|
||||
})
|
||||
}
|
||||
|
||||
// hungCfg 构造用挂起 dialect 的 config。
|
||||
func hungCfg() *config.Config {
|
||||
return &config.Config{Database: config.DatabaseConfig{Driver: "hdb1_hung"}}
|
||||
}
|
||||
|
||||
// TestInitDBBoundsHungDB_Hdb1 回归 H-db-1 启动 master ping 路径:InitDB(Background) 对
|
||||
// 挂起主库的 ping 经 pingWithTimeout 3s 失败,5 次重试后总耗时 ~15s 返回错误,不无限阻塞。
|
||||
// 修复前 initDB 用裸 sqlDB.PingContext(ctx),Background 无 deadline -> 首次 ping 无限阻塞。
|
||||
// 用 ctx 在首次 ping 超时后取消加速(避免 15s 全跑),同时证明 ctx 仍可中断重试循环。
|
||||
func TestInitDBBoundsHungDB_Hdb1(t *testing.T) {
|
||||
registerHungDialect(t)
|
||||
m := NewManager(nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// 4s 后 cancel:首次 ping 经 pingWithTimeout ~3s 超时返回错误,进入重试前 ctx 已取消,
|
||||
// initDB 在重试循环的 ctx.Err() 检查处快速返回。若修复前裸 PingContext(Background)
|
||||
// 首次即无限阻塞,cancel 无法中断 -> assertBoundedPing 超时 fail。
|
||||
go func() { time.Sleep(4 * time.Second); cancel() }()
|
||||
|
||||
_ = assertBoundedPing(t, func() error {
|
||||
return m.InitDB(ctx, hungCfg())
|
||||
}, 6*time.Second)
|
||||
}
|
||||
|
||||
// TestInitDBWithReplicasBoundsHungDB_Hdb1 回归 H-db-1 启动 replica ping 路径:
|
||||
// InitDBWithReplicas 对挂起 replica DSN 的 ping 经 pingWithTimeout 失败(replica 被 continue
|
||||
// 跳过),主库经挂起 dialect 同样 ping 失败。整流程有界返回,不无限阻塞。
|
||||
// 修复前 replica 启动 ping 用裸 PingContext -> 挂起 replica 无限阻塞 InitDBWithReplicas。
|
||||
func TestInitDBWithReplicasBoundsHungDB_Hdb1(t *testing.T) {
|
||||
registerHungDialect(t)
|
||||
m := NewManager(nil)
|
||||
|
||||
// 用可取消 ctx 限制总时长;主库挂起 ping ~3s 失败后 InitDBWithReplicas 直接返回错误
|
||||
// (主库失败不进入 replica 初始化)。此处验证主库 ping 路径有界即可覆盖启动 ping 约束。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(4 * time.Second); cancel() }()
|
||||
|
||||
err := assertBoundedPing(t, func() error {
|
||||
return m.InitDBWithReplicas(ctx, hungCfg(), []string{"hdb1_hung://replica"})
|
||||
}, 6*time.Second)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起主库的 InitDBWithReplicas 应返回错误,got nil")
|
||||
}
|
||||
}
|
||||
|
||||
+14
-8
@@ -68,6 +68,19 @@ func GetDefaultRedisManager() *RedisManager {
|
||||
return DefaultRedis.Load()
|
||||
}
|
||||
|
||||
// newRedisClient 构造带 D7 超时(Dial 5s / Read 3s / Write 3s)的 redis.Client。
|
||||
// 由 Init 与测试共用,确保所有 client 实例一致具备超时约束(挂起 Redis 不无限阻塞)。
|
||||
func newRedisClient(addr, password string, db int) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: db,
|
||||
DialTimeout: 5 * time.Second, // D7 修复:连接超时
|
||||
ReadTimeout: 3 * time.Second, // D7 修复:读超时(约束 HealthCheck Ping 不被挂起 Redis 阻塞)
|
||||
WriteTimeout: 3 * time.Second, // D7 修复:写超时
|
||||
})
|
||||
}
|
||||
|
||||
// Init 初始化 Redis 连接并 ping 验证。
|
||||
func (m *RedisManager) Init(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
@@ -76,14 +89,7 @@ func (m *RedisManager) Init(cfg *config.Config) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
DialTimeout: 5 * time.Second, // D7 修复:连接超时
|
||||
ReadTimeout: 3 * time.Second, // D7 修复:读超时
|
||||
WriteTimeout: 3 * time.Second, // D7 修复:写超时
|
||||
})
|
||||
client := newRedisClient(cfg.Redis.Addr(), cfg.Redis.Password, cfg.Redis.DB)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// startHungRedisListener 启动一个接受 TCP 连接但不读写任何数据的 listener,
|
||||
// 模拟"挂起 Redis"(连接建立但不响应 RESP,区别于宕机的 connection-refused)。
|
||||
// 返回 listener 地址;t.Cleanup 关闭 listener。
|
||||
func startHungRedisListener(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("hung redis listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return // listener 关闭
|
||||
}
|
||||
// 故意不读写、不关闭,持住连接模拟挂起。连接由 t.Cleanup 的 ln.Close 间接清理。
|
||||
_ = conn
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// TestRedisHealthCheckBoundsHungRedis_Hdb1 回归 H-db-1 边界(Redis 侧):
|
||||
// RedisManager.HealthCheck(Background) 对挂起 Redis(连接活但不响应)应受
|
||||
// redis client 的 ReadTimeout(3s,redis.go D7) 约束有界返回错误,不无限阻塞。
|
||||
// ctx 无 deadline 时由 client ReadTimeout 兜底;ctx 自带更短 deadline 时优先尊重 ctx。
|
||||
// 修复前/缺 ReadTimeout 时 Ping(Background) 会无限阻塞。
|
||||
func TestRedisHealthCheckBoundsHungRedis_Hdb1(t *testing.T) {
|
||||
addr := startHungRedisListener(t)
|
||||
cfg := redisTestConfig(t, addr)
|
||||
|
||||
m := NewRedisManager()
|
||||
if err := m.Init(cfg); err == nil {
|
||||
// Init 的 Ping 有 5s ctx,挂起 Redis 下应在 ~3s(ReadTimeout)失败返回错误。
|
||||
t.Fatalf("挂起 Redis 的 Init 应返回错误,got nil")
|
||||
}
|
||||
// Init 失败后 m.client 未被安装(nil),HealthCheck 返 "Redis 未初始化"。
|
||||
// 为测 HealthCheck 自身的 ping 超时约束,需 client 已安装但指向挂起 Redis。
|
||||
// 用 setClientForTest 注入一个指向挂起 Redis 的 client。
|
||||
client := newRedisClientForTest(addr)
|
||||
old := m.setClientForTest(client)
|
||||
t.Cleanup(func() {
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
// HealthCheck(Background):挂起 Redis 下 client.Ping 受 ReadTimeout 3s 约束有界返回。
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() { done <- m.HealthCheck(context.Background()) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatalf("挂起 Redis 的 HealthCheck 应返回错误,got nil")
|
||||
}
|
||||
// 应在 ReadTimeout(3s) 附近返回,给 6s 上限(含连接/重试余量)。
|
||||
if elapsed > 6*time.Second {
|
||||
t.Fatalf("HealthCheck 耗时 %v 超过 6s 上限(应受 ReadTimeout 3s 约束)", elapsed)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("HealthCheck 在挂起 Redis 上无限阻塞(ReadTimeout 未约束 Ping)")
|
||||
}
|
||||
}
|
||||
|
||||
// newRedisClientForTest 构造指向 addr 的 redis.Client(复用 Init 的 client 配置,
|
||||
// 含 DialTimeout 5s / ReadTimeout 3s / WriteTimeout 3s)。
|
||||
func newRedisClientForTest(addr string) *redis.Client {
|
||||
return newRedisClient(addr, "", 0)
|
||||
}
|
||||
Reference in New Issue
Block a user