diff --git a/cmd/server/main.go b/cmd/server/main.go index 57df8a5..0c2c462 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -79,7 +79,7 @@ func main() { "portal_url_set", cfg.PortalURL != "", "portal_key_set", cfg.InternalAPIKey != "") } - healthH := handler.NewHealthHandler(pool) + healthH := handler.NewHealthHandler(pool, store) testH := handler.NewTestHandler(testRepo, accessRepo) attemptH := handler.NewAttemptHandler(attemptRepo, testRepo) courseH := handler.NewCourseHandler(courseRepo, accessRepo) @@ -94,6 +94,7 @@ func main() { r.Get("/healthz", healthH.Healthz) r.Get("/readyz", healthH.Readyz) + r.Get("/health/detail", healthH.Detail) r.Route("/api", func(r chi.Router) { r.Use(commonmw.InternalAuth(cfg.InternalAPIKey)) diff --git a/internal/handler/health.go b/internal/handler/health.go index 4603148..abb513a 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -3,17 +3,21 @@ package handler import ( "context" "net/http" + "strconv" "time" "github.com/jackc/pgx/v5/pgxpool" + + "learning-service/internal/storage" ) type HealthHandler struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + store *storage.Storage } -func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler { - return &HealthHandler{pool: pool} +func NewHealthHandler(pool *pgxpool.Pool, store *storage.Storage) *HealthHandler { + return &HealthHandler{pool: pool, store: store} } // 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"}) } + +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) +} diff --git a/internal/storage/minio.go b/internal/storage/minio.go index c153941..d75137f 100644 --- a/internal/storage/minio.go +++ b/internal/storage/minio.go @@ -72,6 +72,20 @@ func (s *Storage) EnsureBucket(ctx context.Context) error { 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'е. Структура: /., // где random — короткий uuid для anti-cache + защита от перезаписи случайно. // При замене видео ставится новый key, старый объект остаётся в MinIO