diff --git a/CHANGELOG.md b/CHANGELOG.md index d12361b..3913da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-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 条路径。 - **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/database/manager.go b/database/manager.go index 201e937..fec5d25 100644 --- a/database/manager.go +++ b/database/manager.go @@ -304,7 +304,8 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) { } continue } - if err := sqlDB.PingContext(ctx); err != nil { + // M11(H-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 { + // M11(H-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 { + // M11(H-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 diff --git a/database/manager_hdb1_internal_test.go b/database/manager_hdb1_internal_test.go new file mode 100644 index 0000000..f9ecaf1 --- /dev/null +++ b/database/manager_hdb1_internal_test.go @@ -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-1:ping 路径须经 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 返回;超时则 fail(H-db-1:ping 应被 +// 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 超过上限 %v(H-db-1:应被 pingWithTimeout 3s 约束)", elapsed, max) + } + return err + case <-time.After(max + 2*time.Second): + t.Fatalf("ping 路径在挂起 DB 上无限阻塞(H-db-1:pingWithTimeout 未覆盖该路径)") + 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 返回超时错误,不无限阻塞。该路径被后台探活 +// probeOnce(master)与 /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") + } +}