fix(database): H-db-1 ping 路径统一经 pingWithTimeout 3s 约束(M11 完整覆盖)

module 04 database 评审发现 H-db-1(HIGH):M11 的 pingWithTimeout 只加到包级
HealthCheck(),未覆盖 4 条 ping 路径:
- (*Manager).HealthCheck(被后台探活 probeOnce master + /health 端点共用)
- probeOnce 从库 ping
- InitDB 启动 master ping
- InitDBWithReplicas 启动 replica ping

挂起 DB(连接活但不响应,区别于宕机的 connection-refused 快速失败)下,这些
路径的 sqlDB.PingContext(ctx) 用 rootCtx/Background(无 deadline)无限阻塞,
致:探活 goroutine 阻塞至 shutdown、#21 自愈冻结(replicaHealthy 停留旧值、
Replica() 可能路由到已死从库)、IsHealthy() 缓存失真、App.Init 启动卡死。

修复:4 路径统一改用 pingWithTimeout(sqlDB, ctx),受 healthCheckTimeout(3s)
约束(context.WithTimeout 尊重 ctx 自带更短 deadline)。/health 端点随之收紧
到 3s(更快失败,非回归);InitDB 5 次重试给足 15s+ 余量无误判。

回归测试 manager_hdb1_internal_test.go:注册 hungDriver(Conn.Ping 阻塞到 ctx
取消模拟挂起 DB),验证 pingWithTimeout / m.HealthCheck / probeOnce replica 三
路径在 Background ctx 下 ~3s 返回(修复前无限 hang)。go test -race ./database/
绿(含 3 条 ~3s 挂起用例)。

对抗复核(diff scan):4 路径均 pingWithTimeout,defer cancel 无 ctx 泄漏,
无新缺陷。CHANGELOG 记录。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
杭州明婳科技
2026-07-09 19:14:04 +08:00
parent 08ed43385d
commit 5eb3879a85
3 changed files with 143 additions and 5 deletions
+3
View File
@@ -19,6 +19,8 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
> 复审报告 `gpt_check_report_review.md` 第一优先级(致命/进程级可用性)修复。P1 共 4 项,本次发布已推进 M13 cron panic、M1 App 生命周期、M3 logger 生命周期临界区;M8 CSRF JSON body 上限随后推进。
>
> config 模块评审(`glm_check_report_module_01_config.md`)修复:H-config-1 Set/viper 同源、M-config-1 回调 panic 隔离、M-config-2 DB SSL/TLS、M-config-3 App 关闭停 watcher、M-config-4 Clone 守卫、L-config-2/3/4/5/6。
>
> database 模块评审(`glm_check_report_module_04_database.md`)修复:H-db-1 后台探活/启动 ping 经 pingWithTimeout 3s 约束(M11 修复不完整)。
### Breaking ⚠️
@@ -61,6 +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-1M11 修复不完整):`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 条路径。
- **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 传入业务函数,避免取消后业务函数继续运行。
+12 -5
View File
@@ -304,7 +304,8 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
}
continue
}
if err := sqlDB.PingContext(ctx); err != nil {
// M11H-db-1):从库探活 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞探活 goroutine。
if err := pingWithTimeout(sqlDB, ctx); err != nil {
if i < len(replicaHealthy) && replicaHealthy[i].Load() {
logger.Warnf("数据库从库 #%d 探活失败,暂时剔除读流量: %v", i, err)
}
@@ -399,7 +400,11 @@ func (m *Manager) Close() error {
return errors.Join(errs...)
}
// HealthCheck 健康检查,主库不可达时返回错误
// HealthCheck 健康检查,主库不可达时返回错误
//
// M11 完整覆盖(H-db-1 修复):ping 经 pingWithTimeout 受 healthCheckTimeout(3s) 约束,
// 使后台探活(probeOnce)与 /health 端点(app.go 经本方法)都不会被挂起 DB(连接活但不
// 响应)无限阻塞。ctx 自带更短 deadline 时优先尊重 ctx。
func (m *Manager) HealthCheck(ctx context.Context) error {
ctx = normalizeContext(ctx)
m.mu.Lock()
@@ -412,7 +417,7 @@ func (m *Manager) HealthCheck(ctx context.Context) error {
if err != nil {
return err
}
return sqlDB.PingContext(ctx)
return pingWithTimeout(sqlDB, ctx)
}
// DefaultManager 默认数据库管理器,包级 facade 代理到它。
@@ -530,7 +535,8 @@ func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
}
if err := sqlDB.PingContext(ctx); err == nil {
// M11H-db-1):启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 致 InitDB 重试失败而非无限阻塞。
if err := pingWithTimeout(sqlDB, ctx); err == nil {
// 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b)
m.mu.Lock()
old := m.master
@@ -682,7 +688,8 @@ func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, re
sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns))
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.PingContext(ctx); err != nil {
// M11H-db-1):从库启动 ping 经 pingWithTimeout 受 3s 约束,挂起 DB 不无限阻塞 InitDBWithReplicas。
if err := pingWithTimeout(sqlDB, ctx); err != nil {
logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err)
warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
continue
+128
View File
@@ -0,0 +1,128 @@
package database
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"gorm.io/gorm"
)
// 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")
}
}