项目文件夹

文件
杭州明婳科技 6730f12cd6 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>
2026-07-10 00:52:17 +08:00

200 行
8.3 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package database
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"sync/atomic"
"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 取消,
// 模拟"挂起 DB"(TCP 连接活但不响应查询,区别于宕机的 connection-refused 快速失败)。
// 用于回归 H-db-1ping 路径须经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
// 挂起 DB 不得无限阻塞探活 goroutine / 启动 / /health 端点。
type hungDriver struct{}
func (hungDriver) Open(name string) (driver.Conn, error) { return hungConn{}, nil }
type hungConn struct{}
func (hungConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("hung: not implemented") }
func (hungConn) Close() error { return nil }
func (hungConn) Begin() (driver.Tx, error) { return nil, errors.New("hung: not implemented") }
// Ping 阻塞直到 ctx 取消(模拟挂起 DB 永不响应 ping)。实现 driver.Pinger 接口
// (方法名为 Ping 而非 PingContext,否则 *sql.DB.PingContext 视为 no-op 返回 nil)。
func (hungConn) Ping(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
var registerHungOnce sync.Once
func registerHungDriver() {
registerHungOnce.Do(func() { sql.Register("xlgo_hung_hdb1", hungDriver{}) })
}
func newHungSqlDB(t *testing.T) *sql.DB {
t.Helper()
registerHungDriver()
db, err := sql.Open("xlgo_hung_hdb1", "")
if err != nil {
t.Fatalf("sql.Open hung driver: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// newHungGormDB 构造底层 *sql.DB 为挂起驱动的 gorm.DB,其 DB() 返回挂起 *sql.DB。
func newHungGormDB(t *testing.T) *gorm.DB {
t.Helper()
return &gorm.DB{Config: &gorm.Config{ConnPool: newHungSqlDB(t)}}
}
// assertBoundedPing 在 max+2s 内等待 fn 返回;超时则 failH-db-1ping 应被
// pingWithTimeout 3s 约束,不应无限 hang)。返回 fn 的 error 供调用方断言。
func assertBoundedPing(t *testing.T, fn func() error, max time.Duration) error {
t.Helper()
done := make(chan error, 1)
start := time.Now()
go func() { done <- fn() }()
select {
case err := <-done:
if elapsed := time.Since(start); elapsed > max {
t.Fatalf("ping 路径耗时 %v 超过上限 %vH-db-1:应被 pingWithTimeout 3s 约束)", elapsed, max)
}
return err
case <-time.After(max + 2*time.Second):
t.Fatalf("ping 路径在挂起 DB 上无限阻塞(H-db-1pingWithTimeout 未覆盖该路径)")
return nil
}
}
// TestPingWithTimeoutBoundsHungDB_Hdb1 回归 H-db-1 机制:pingWithTimeout 对挂起 DB 的
// *sql.DB.PingContext 限 healthCheckTimeout 返回,不因 ctx 无 deadline 无限阻塞。
// 修复前若直接 PingContext(Background()) 会无限阻塞。
func TestPingWithTimeoutBoundsHungDB_Hdb1(t *testing.T) {
sqlDB := newHungSqlDB(t)
err := assertBoundedPing(t, func() error {
return pingWithTimeout(sqlDB, context.Background())
}, healthCheckTimeout+1*time.Second)
if err == nil {
t.Fatalf("挂起 DB 的 ping 应返回超时错误,got nil")
}
}
// TestManagerHealthCheckBoundsHungDB_Hdb1 回归 H-db-1 主路径:m.HealthCheck(Background)
// 对挂起主库经 pingWithTimeout ~3s 返回超时错误,不无限阻塞。该路径被后台探活
// probeOncemaster)与 /health 端点(app.go 经 dbm.HealthCheck)共用。
// 修复前 m.HealthCheck 用裸 sqlDB.PingContext(ctx),Background ctx 无 deadline -> 无限阻塞。
func TestManagerHealthCheckBoundsHungDB_Hdb1(t *testing.T) {
m := NewManager(nil)
m.master = newHungGormDB(t)
err := assertBoundedPing(t, func() error {
return m.HealthCheck(context.Background())
}, healthCheckTimeout+1*time.Second)
if err == nil {
t.Fatalf("挂起主库的 HealthCheck 应返回超时错误,got nil")
}
}
// TestProbeOnceReplicaBoundsHungDB_Hdb1 回归 H-db-1 从库探活路径:probeOnce 对挂起从库
// 经 pingWithTimeout ~3s 返回,不无限阻塞探活 goroutine(#21 自愈不冻结)。master 置 nil
// 使 HealthCheck 快速返回"未初始化",让 probeOnce 只在从库 ping 路径耗时。
// 修复前 probeOnce 从库用裸 sqlDB.PingContext(ctx),Background ctx 无 deadline -> 无限阻塞。
func TestProbeOnceReplicaBoundsHungDB_Hdb1(t *testing.T) {
m := NewManager(nil)
m.master = nil // master 路径快速失败,集中测从库 ping 超时
m.replicas = []*gorm.DB{newHungGormDB(t)}
m.replicaHealthSet = true
m.replicaHealthy = make([]atomic.Bool, 1)
m.replicaHealthy[0].Store(true)
_ = assertBoundedPing(t, func() error {
m.probeOnce(context.Background(), 3)
return nil
}, healthCheckTimeout+1*time.Second)
// 挂起从库应被标记不健康(剔除读流量,#21 自愈生效)
if m.replicaHealthy[0].Load() {
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")
}
}