feat: expose learning video health detail
Some checks failed
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 27s
CI / test (push) Failing after 19s

This commit is contained in:
Grendgi
2026-06-17 16:03:25 +03:00
parent ae2ac23a3a
commit 5ad2a8a33e
3 changed files with 85 additions and 4 deletions

View File

@@ -79,7 +79,7 @@ func main() {
"portal_url_set", cfg.PortalURL != "", "portal_key_set", cfg.InternalAPIKey != "") "portal_url_set", cfg.PortalURL != "", "portal_key_set", cfg.InternalAPIKey != "")
} }
healthH := handler.NewHealthHandler(pool) healthH := handler.NewHealthHandler(pool, store)
testH := handler.NewTestHandler(testRepo, accessRepo) testH := handler.NewTestHandler(testRepo, accessRepo)
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo) attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
courseH := handler.NewCourseHandler(courseRepo, accessRepo) courseH := handler.NewCourseHandler(courseRepo, accessRepo)
@@ -94,6 +94,7 @@ func main() {
r.Get("/healthz", healthH.Healthz) r.Get("/healthz", healthH.Healthz)
r.Get("/readyz", healthH.Readyz) r.Get("/readyz", healthH.Readyz)
r.Get("/health/detail", healthH.Detail)
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey)) r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))

View File

@@ -3,17 +3,21 @@ package handler
import ( import (
"context" "context"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"learning-service/internal/storage"
) )
type HealthHandler struct { type HealthHandler struct {
pool *pgxpool.Pool pool *pgxpool.Pool
store *storage.Storage
} }
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler { func NewHealthHandler(pool *pgxpool.Pool, store *storage.Storage) *HealthHandler {
return &HealthHandler{pool: pool} return &HealthHandler{pool: pool, store: store}
} }
// Healthz — liveness. Не дёргает БД; жив если процесс отвечает. // Healthz — liveness. Не дёргает БД; жив если процесс отвечает.
@@ -32,3 +36,65 @@ func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
} }
func (h *HealthHandler) Detail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
components := []componentProbe{
h.probePostgres(ctx),
h.probeVideoStorage(ctx),
h.probeVideoMetadata(ctx),
}
writeJSON(w, http.StatusOK, map[string]any{"components": components})
}
type componentProbe struct {
Name string `json:"name"`
Status string `json:"status"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
func (h *HealthHandler) probePostgres(ctx context.Context) componentProbe {
start := time.Now()
if err := h.pool.Ping(ctx); err != nil {
return componentProbe{Name: "postgres", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "postgres", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeVideoStorage(ctx context.Context) componentProbe {
start := time.Now()
if err := h.store.Check(ctx); err != nil {
return componentProbe{Name: "video_storage", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "video_storage", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (h *HealthHandler) probeVideoMetadata(ctx context.Context) componentProbe {
start := time.Now()
var videos, broken int
err := h.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE video_key <> '')::int,
COUNT(*) FILTER (WHERE video_key <> '' AND btrim(video_key) = '')::int
FROM lessons`,
).Scan(&videos, &broken)
if err != nil {
return componentProbe{Name: "video_metadata", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
if broken > 0 {
return componentProbe{
Name: "video_metadata",
Status: "down",
LatencyMs: time.Since(start).Milliseconds(),
Error: "videos=" + intString(videos) + " broken_video_keys=" + intString(broken),
}
}
return componentProbe{Name: "video_metadata", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func intString(v int) string {
return strconv.Itoa(v)
}

View File

@@ -72,6 +72,20 @@ func (s *Storage) EnsureBucket(ctx context.Context) error {
return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{}) return s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{})
} }
func (s *Storage) Check(ctx context.Context) error {
if !s.Configured() {
return errors.New("storage not configured")
}
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
if err != nil {
return fmt.Errorf("check bucket: %w", err)
}
if !exists {
return fmt.Errorf("bucket not found: %s", s.cfg.Bucket)
}
return nil
}
// GenerateKey — путь объекта в bucket'е. Структура: <lesson_id>/<random>.<ext>, // GenerateKey — путь объекта в bucket'е. Структура: <lesson_id>/<random>.<ext>,
// где random — короткий uuid для anti-cache + защита от перезаписи случайно. // где random — короткий uuid для anti-cache + защита от перезаписи случайно.
// При замене видео ставится новый key, старый объект остаётся в MinIO // При замене видео ставится новый key, старый объект остаётся в MinIO