From 6730f12cd6664c44ae512a599a264fcc289d5798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=B7=9E=E6=98=8E=E5=A9=B3=E7=A7=91=E6=8A=80?= Date: Fri, 10 Jul 2026 00:52:17 +0800 Subject: [PATCH] =?UTF-8?q?test(database):=20H-db-1=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E9=97=AD=E7=8E=AF=20+=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20gorm.Open=20automatic=20ping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交(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) --- CHANGELOG.md | 2 +- app_hdb1_test.go | 116 +++++++++++++++++++++++++ database/manager.go | 7 ++ database/manager_hdb1_internal_test.go | 71 +++++++++++++++ database/redis.go | 22 +++-- database/redis_hdb1_internal_test.go | 84 ++++++++++++++++++ 6 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 app_hdb1_test.go create mode 100644 database/redis_hdb1_internal_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3913da4..0ced541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 传入业务函数,避免取消后业务函数继续运行。 diff --git a/app_hdb1_test.go b/app_hdb1_test.go new file mode 100644 index 0000000..d58e736 --- /dev/null +++ b/app_hdb1_test.go @@ -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() +} diff --git a/database/manager.go b/database/manager.go index fec5d25..8224e85 100644 --- a/database/manager.go +++ b/database/manager.go @@ -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= 生效。 @@ -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, } // 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态 diff --git a/database/manager_hdb1_internal_test.go b/database/manager_hdb1_internal_test.go index f9ecaf1..e107170 100644 --- a/database/manager_hdb1_internal_test.go +++ b/database/manager_hdb1_internal_test.go @@ -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") + } +} diff --git a/database/redis.go b/database/redis.go index 7fe2ef5..13b7fa5 100644 --- a/database/redis.go +++ b/database/redis.go @@ -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() diff --git a/database/redis_hdb1_internal_test.go b/database/redis_hdb1_internal_test.go new file mode 100644 index 0000000..b00dec9 --- /dev/null +++ b/database/redis_hdb1_internal_test.go @@ -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) +}