153 lines
4.2 KiB
Go
153 lines
4.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/andydixon/relata/internal/domain"
|
|
"github.com/andydixon/relata/internal/observability"
|
|
)
|
|
|
|
// HealthChecker reports whether Relata and its dependencies are working.
|
|
type HealthChecker struct {
|
|
Mongo func(context.Context) error
|
|
Vectors domain.VectorStore
|
|
Embedder domain.HealthChecker
|
|
Analyser domain.HealthChecker
|
|
Version string
|
|
Started time.Time
|
|
}
|
|
|
|
// checkResult is one dependency's status.
|
|
type checkResult struct {
|
|
Status string `json:"status"`
|
|
Detail string `json:"detail,omitempty"`
|
|
// Required marks dependencies without which Relata cannot serve requests.
|
|
Required bool `json:"required"`
|
|
}
|
|
|
|
// Live reports that the process is running.
|
|
//
|
|
// It deliberately checks nothing else: a liveness probe that fails when a
|
|
// database is briefly unavailable causes an orchestrator to restart a process
|
|
// that was working perfectly well.
|
|
func (h *HealthChecker) Live(w http.ResponseWriter, r *http.Request) {
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"version": h.Version,
|
|
"uptime_sec": int(time.Since(h.Started).Seconds()),
|
|
})
|
|
}
|
|
|
|
// Ready reports whether Relata can serve requests.
|
|
//
|
|
// MongoDB and Qdrant are required: without them nothing can be stored or
|
|
// retrieved. The AI providers are reported but not required, because Relata's
|
|
// deterministic pipeline, search and browsing all work without a model — saying
|
|
// otherwise would take the whole service down when Ollama restarts.
|
|
func (h *HealthChecker) Ready(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
checks := map[string]checkResult{}
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
|
|
set := func(name string, res checkResult) {
|
|
mu.Lock()
|
|
checks[name] = res
|
|
mu.Unlock()
|
|
}
|
|
|
|
// unavailable records a failed dependency. The driver's own error text is
|
|
// logged but never returned: readiness probes are unauthenticated, and
|
|
// MongoDB's topology errors carry the host, port and sometimes the
|
|
// credentials from the connection string. The caller gets a stable
|
|
// description; the operator gets the specifics in the log.
|
|
unavailable := func(name, detail string, required bool, err error) {
|
|
slog.WarnContext(ctx, "readiness check failed",
|
|
"dependency", name, "error", err, "request_id", observability.RequestID(ctx))
|
|
set(name, checkResult{Status: "unavailable", Detail: detail, Required: required})
|
|
}
|
|
|
|
if h.Mongo != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := h.Mongo(ctx); err != nil {
|
|
unavailable("mongodb",
|
|
"the database did not respond to a health check; see the server log for the cause",
|
|
true, err)
|
|
return
|
|
}
|
|
set("mongodb", checkResult{Status: "ok", Required: true})
|
|
}()
|
|
}
|
|
|
|
if h.Vectors != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := h.Vectors.Health(ctx); err != nil {
|
|
unavailable("qdrant",
|
|
"the vector store did not respond to a health check; see the server log for the cause",
|
|
true, err)
|
|
return
|
|
}
|
|
set("qdrant", checkResult{Status: "ok", Required: true})
|
|
}()
|
|
}
|
|
|
|
if h.Embedder != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
st := h.Embedder.Health(ctx)
|
|
if !st.Available {
|
|
unavailable("embedding_provider",
|
|
"the embedding model is not reachable; new items cannot be embedded",
|
|
false, errors.New(st.Detail))
|
|
return
|
|
}
|
|
set("embedding_provider", checkResult{Status: "ok"})
|
|
}()
|
|
}
|
|
|
|
if h.Analyser != nil {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
st := h.Analyser.Health(ctx)
|
|
if !st.Available {
|
|
unavailable("analysis_provider",
|
|
"the analysis model is not reachable; extraction falls back to deterministic rules only",
|
|
false, errors.New(st.Detail))
|
|
return
|
|
}
|
|
set("analysis_provider", checkResult{Status: "ok"})
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
status := http.StatusOK
|
|
overall := "ready"
|
|
for _, c := range checks {
|
|
if c.Required && c.Status != "ok" {
|
|
status = http.StatusServiceUnavailable
|
|
overall = "not ready"
|
|
}
|
|
}
|
|
|
|
WriteJSON(w, status, map[string]any{
|
|
"status": overall,
|
|
"checks": checks,
|
|
"version": h.Version,
|
|
"note": "AI providers are reported but not required: ingestion, search and browsing work without them.",
|
|
})
|
|
}
|