mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-26 18:22:38 +00:00
* Answer statement timeouts with 503 on every read endpoint * Keep the plan check on 500; log the cause on read-model pages too
115 lines
4.5 KiB
Go
115 lines
4.5 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// DBStatementsReader reads statement statistics grouped by sqlc name.
|
|
type DBStatementsReader interface {
|
|
DBStatements(context.Context) ([]api.DBStatement, error)
|
|
}
|
|
|
|
// PlanguardSource holds the daily plan regression check.
|
|
type PlanguardSource interface {
|
|
Last() (api.PlanguardReport, error) // api.ErrPlanguardNotRun before the first run; api.ErrPlanguardDisabled when off
|
|
RunNow(ctx context.Context) (api.PlanguardReport, error)
|
|
}
|
|
|
|
// DBAdminRouter serves database diagnostics under the admin router. With pg nil, /planguard is not served.
|
|
func DBAdminRouter(reader DBStatementsReader, pg PlanguardSource) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Get("/statements", getDBStatements(reader))
|
|
if pg != nil {
|
|
r.Get("/planguard", getPlanguard(pg))
|
|
}
|
|
return r
|
|
}
|
|
|
|
// getPlanguard godoc
|
|
//
|
|
// @Summary Last daily plan regression check
|
|
// @Description Plans every corpus statement in custom and generic mode against this database, read-only and without executing it (plain EXPLAIN, 5 s per plan, 1 s lock timeout), and checks each plan against the corpus. Off unless planguard.daily_check is set (404 "planguard daily check is disabled"); when on, the check runs about an hour after boot and then daily, and until its first run this returns 404. fresh=true runs it now, shared with any run in progress and cancelled at server shutdown; within 5 minutes of the last run's end it starts nothing and returns the last successful report (started_at gives its age), or 500 when no run has succeeded yet. A read-only server cannot enable the check (configuration refuses planguard.daily_check with server.read_only), so there this returns 404 "planguard daily check is disabled".
|
|
// @Tags Admin
|
|
// @Produce json
|
|
// @Security AdminKey
|
|
// @Param fresh query bool false "Run the check now instead of returning the last report"
|
|
// @Success 200 {object} api.PlanguardReport
|
|
// @Failure 400,401,404,500 {object} map[string]APIError
|
|
// @Router /admin/db/planguard [get]
|
|
func getPlanguard(pg PlanguardSource) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
fresh := false
|
|
if v := r.URL.Query().Get("fresh"); v != "" {
|
|
b, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "invalid fresh value")
|
|
return
|
|
}
|
|
fresh = b
|
|
}
|
|
if fresh {
|
|
rep, err := pg.RunNow(r.Context())
|
|
if errors.Is(err, api.ErrPlanguardDisabled) {
|
|
respondError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
slog.Error("api: plan check", "component", "api", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "cannot run the plan check")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, rep)
|
|
return
|
|
}
|
|
rep, err := pg.Last()
|
|
if errors.Is(err, api.ErrPlanguardNotRun) || errors.Is(err, api.ErrPlanguardDisabled) {
|
|
respondError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
slog.Error("api: read plan check", "component", "api", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "cannot read the plan check")
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, rep)
|
|
}
|
|
}
|
|
|
|
// getDBStatements godoc
|
|
//
|
|
// @Summary Statement statistics by sqlc name
|
|
// @Description Sums pg_stat_statements for this database by sqlc statement name, highest total execution time first. Statements without a name are summed as "(unnamed)". Times are milliseconds rounded to microseconds; plan_ms stays 0 unless pg_stat_statements.track_planning is on. Counters run from the last pg_stat_statements reset.
|
|
// @Tags Admin
|
|
// @Produce json
|
|
// @Security AdminKey
|
|
// @Success 200 {object} api.DBStatementsResponse
|
|
// @Failure 401,500,503 {object} map[string]APIError
|
|
// @Router /admin/db/statements [get]
|
|
func getDBStatements(reader DBStatementsReader) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
items, err := reader.DBStatements(r.Context())
|
|
if errors.Is(err, api.ErrStatementsUnavailable) {
|
|
respondError(w, http.StatusServiceUnavailable, "pg_stat_statements is not available: create the extension and add it to shared_preload_libraries")
|
|
return
|
|
}
|
|
if err != nil {
|
|
respondReaderError(w, err, "cannot read statement statistics")
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []api.DBStatement{}
|
|
}
|
|
respond(w, http.StatusOK, api.DBStatementsResponse{Items: items})
|
|
}
|
|
}
|