diff --git a/VERSION b/VERSION index 92c57b6..bfaac8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.1.2 +dev_0.1.3 diff --git a/server/internal/httpapi/error_surface_test.go b/server/internal/httpapi/error_surface_test.go index 4f3ca62..23c6cdc 100644 --- a/server/internal/httpapi/error_surface_test.go +++ b/server/internal/httpapi/error_surface_test.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" "testing" + "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) @@ -110,13 +111,15 @@ func TestHealthReportsDatabaseProbe(t *testing.T) { t.Fatalf("正常时应为 ok/db=ok/version=test,得到 %+v", healthy) } - // 把赛事库关掉:HTTP 还活着,探库必须失败,且状态码仍然是 200。 + // 把赛事库关掉:HTTP 还活着,探库必须报 error,且状态码仍然是 200 —— + // 前端 health() 见到 ok:false 会直接抛错,状态码再变 5xx 只会让「关于」页 + // 从「版本未知」升级成「服务不可达」,对现场没有额外信息。 if err := env.ev.Close(); err != nil { t.Fatalf("关闭赛事库: %v", err) } status, body = env.doRaw(client, http.MethodGet, "/api/health", nil, "", false) if status != http.StatusOK { - t.Fatalf("数据库故障时状态码仍应是 200(避免 Console 重启循环),得到 %d %s", status, body) + t.Fatalf("数据库故障时状态码仍应是 200,得到 %d %s", status, body) } var broken struct { OK bool `json:"ok"` @@ -130,3 +133,49 @@ func TestHealthReportsDatabaseProbe(t *testing.T) { t.Fatalf("探库失败应报 db=error 并说明原因,得到 %+v", broken) } } + +// 探库被长操作占住时,健康检查自己不能跟着一起卡住。 +// +// 数据库是单连接,备份(`VACUUM INTO`)或整包重算会把它占住一段时间; +// 如果探针的超时大于 Console 的探活超时(2500ms),这期间 /api/health 会整条超时, +// Console 就把服务标成「未就绪」(状态灯变红、托盘提示异常,`index.js` 还会跳过大屏重排)—— +// 明明服务是好的,现场却看到红的。 +func TestHealthProbeStaysBoundedWhenDatabaseBusy(t *testing.T) { + env := newTestEnv(t) + client, _ := env.client() + + // 占住赛事库的唯一连接,模拟备份 / 重算。 + tx, err := env.ev.Begin() + if err != nil { + t.Fatalf("开启事务: %v", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec(`SELECT 1`); err != nil { + t.Fatalf("占用连接失败: %v", err) + } + + start := time.Now() + status, body := env.doRaw(client, http.MethodGet, "/api/health", nil, "", false) + elapsed := time.Since(start) + + if status != http.StatusOK { + t.Fatalf("连接被占时健康检查仍应回 200,得到 %d %s", status, body) + } + if elapsed > 2000*time.Millisecond { + t.Fatalf("探库被占用时健康检查耗时 %v,已逼近 Console 的 2500ms 探活超时", elapsed) + } + var busy struct { + OK bool `json:"ok"` + DB string `json:"db"` + DBError string `json:"dbError"` + } + if err := json.Unmarshal(body, &busy); err != nil { + t.Fatalf("解析健康检查响应: %v (%s)", err, body) + } + if busy.DB != "busy" { + t.Fatalf("连接被占用应报 db=busy(而不是笼统的 error),得到 %+v", busy) + } + if !busy.OK || busy.DBError == "" { + t.Fatalf("busy 时 ok 仍应为 true 并说明原因,得到 %+v", busy) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 994aff2..41b92d6 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -4,6 +4,7 @@ package httpapi import ( "context" "database/sql" + "errors" "fmt" "io/fs" "log/slog" @@ -167,18 +168,20 @@ func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *htt func (s *server) routes(mux *http.ServeMux) { // 健康检查不走 {ok,data} 包装(05 第 7 节)。 // - // 语义分两层,都写进响应体的不同字段,谁也不覆盖谁: - // ok —— HTTP 服务活着(Console 拿状态码当存活信号,这里恒回 200); - // db —— 数据库能应答(新增,之前不探库,「进程活着但连不上库」会被当成健康)。 - // 状态码保持 200:Console 判定不健康会重启进程,而数据库故障重启治不了, - // 只会变成重启循环。让守护脚本读 db 字段自行决策,比替它做决定安全。 - // 不返回最近备份时间:本接口会被 Console 高频轮询,不做目录扫描。 + // 语义分两层,写进不同字段,谁也不覆盖谁: + // ok —— HTTP 服务活着(**恒为 true,状态码也恒 200**); + // db —— 数据库能不能应答(ok / busy / error)。 + // 状态码不做区分:Console 只在**进程真的退出**时才拉起(`service.js` 的 handleExit), + // 但前端 `health()` 一见到 ok:false 就直接抛错(管理端「关于」页会显示 + // 「未知(/api/health 不可达)」),一次数据库抖动不该让界面宣称版本未知。 + // 进程存活与依赖状态本来就是两件事,分字段表达比塞进一个布尔值诚实。 + // 不返回最近备份时间:本接口会被 Console 每 5 秒轮询,不做目录扫描。 mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), healthProbeTimeout) defer cancel() - resp := map[string]any{"ok": true, "version": s.version, "db": "ok"} - if err := s.probeDatabases(ctx); err != nil { - resp["db"] = "error" + state, err := s.probeDatabases(ctx) + resp := map[string]any{"ok": true, "version": s.version, "db": state} + if err != nil { resp["dbError"] = err.Error() } writeJSON(w, http.StatusOK, resp) @@ -301,14 +304,24 @@ func (s *server) notWired(name string) http.HandlerFunc { } // healthProbeTimeout 是健康检查探库的超时。 -// 取 5s 与 busy_timeout 同量级:库被备份、重算这类长事务占着单连接时, -// 探针宁可等一会儿,也不要报出一个假的「数据库故障」。 -const healthProbeTimeout = 5 * time.Second +// +// **必须明显小于 Console 的健康检查超时**(`console/main/service.js` 里 `timeout: 2500`): +// 数据库是单连接(`SetMaxOpenConns(1)`),被备份(`VACUUM INTO`)或整包重算这类长操作 +// 占着的时候,探针会一直等到自己的超时——探针超时就是**整个 /api/health 请求超时**, +// Console 会把服务标成「未就绪」(状态灯变红、托盘提示异常;`console/main/index.js:671` +// 在未就绪时还会跳过重排大屏)。1 秒对一次 `SELECT 1` 绰绰有余——正常是亚毫秒, +// 走满超时只可能是「连接被占用」这一种情况,那就如实报 busy,别拖垮探活请求本身。 +const healthProbeTimeout = time.Second -// probeDatabases 探活系统库与赛事库,返回第一个出错的原因。 +// probeDatabases 探活系统库与赛事库,返回状态与原因: +// +// "ok" —— 两个库都答得上话; +// "busy" —— 超时前没拿到连接(多半是备份 / 重算占着,未必是故障); +// "error" —— 真的报错了(例如库文件被删、损坏)。 +// // 用 `SELECT 1` 而不是 Ping:sqlite 驱动的 Ping 可能只到连接池就返回, // 探不到「文件还在、库已经读不动」这种情况。 -func (s *server) probeDatabases(ctx context.Context) error { +func (s *server) probeDatabases(ctx context.Context) (string, error) { for _, target := range []struct { name string db *sql.DB @@ -318,10 +331,14 @@ func (s *server) probeDatabases(ctx context.Context) error { } var one int if err := target.db.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil { - return fmt.Errorf("%s 库无响应: %w", target.name, err) + if errors.Is(err, context.DeadlineExceeded) { + return "busy", fmt.Errorf("%s 库在 %s 内没应答(连接被备份/重算占用?): %w", + target.name, healthProbeTimeout, err) + } + return "error", fmt.Errorf("%s 库无响应: %w", target.name, err) } } - return nil + return "ok", nil } // staticHandler 把 URL 映射到多页面前端产物。