feat: expose learning video health detail
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user