Files
xlgo-core/database/manager_hdb1_internal_test.go
2026-07-14 10:24:10 +08:00

200 lines
8.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}