M7: ADR-040 Redis Pub/Sub as state routing backplane
- push-gateway: remove Kafka consumer, add SSE endpoint
- SSE: subscribe to Redis user:{userId}:notify on connect
- msg: publish notifications to Redis Pub/Sub instead of HTTP push
- docker-compose: remove Kafka env from push-gateway
159 lines
5.3 KiB
Go
159 lines
5.3 KiB
Go
// Package health implements the /healthz (liveness) and /readyz (readiness)
|
|
// endpoints for push-gateway.
|
|
//
|
|
// /healthz is a trivial liveness probe: it returns 200 + {status:ok} as long
|
|
// as the process is running and the Gin router is serving.
|
|
//
|
|
// /readyz reports readiness based on downstream dependency health. Per
|
|
// ARB-015 §17.4 / ISSUE-058 the readiness probe uses SOFT FAILURE semantics:
|
|
// when Redis is unavailable the endpoint still returns HTTP 200 but carries
|
|
// `degraded: true` plus a `dependencies` block describing which component
|
|
// failed. This prevents Kubernetes from evicting the pod when a transient
|
|
// dependency blip occurs, at the cost of accepting some degraded behavior
|
|
// (no Redis Pub/Sub fanout, no SSE delivery) during the blip. Only the Hub
|
|
// being in a shutting-down state returns a non-200 (503).
|
|
//
|
|
// M7 (ADR-040): Kafka probe removed — push-gateway no longer mounts Kafka.
|
|
//
|
|
// The hard-failure case is limited to:
|
|
// - Hub.closing == true (process is shutting down) -> 503
|
|
// - Redis missing in non-DevMode -> still 200 + degraded, since the pod can
|
|
// still serve local WebSocket traffic.
|
|
package health
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/edu-cloud/push-gateway/internal/hub"
|
|
"github.com/edu-cloud/push-gateway/internal/redisclient"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// probeTimeout bounds each dependency check so a hung dependency cannot stall
|
|
// the readiness probe. Kubernetes typically expects /readyz to respond within
|
|
// 5s; we use 1s per probe to leave headroom.
|
|
const probeTimeout = 1 * time.Second
|
|
|
|
// dependencyStatus is the per-component health snapshot included in the
|
|
// /readyz response body.
|
|
type dependencyStatus struct {
|
|
Ok bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
Latency int64 `json:"latency_ms,omitempty"`
|
|
}
|
|
|
|
// readyzResponse is the ActionState-shaped envelope returned by /readyz.
|
|
// `degraded` is true when at least one non-critical dependency is unhealthy.
|
|
type readyzResponse struct {
|
|
Status string `json:"status"`
|
|
Service string `json:"service"`
|
|
InstanceID string `json:"instance_id"`
|
|
Degraded bool `json:"degraded"`
|
|
Connections int `json:"connections"`
|
|
Users int `json:"users"`
|
|
Dependencies map[string]*dependencyStatus `json:"dependencies"`
|
|
}
|
|
|
|
// Healthz returns a trivial liveness handler: 200 + {status:ok, service}.
|
|
// Liveness never depends on downstream components — if the process can serve
|
|
// the request, it is alive.
|
|
func Healthz(service string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "ok",
|
|
"service": service,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Readyzer builds the /readyz handler. It probes Redis (PING) and reports
|
|
// degraded state per ARB-015 §17.4.
|
|
//
|
|
// The Hub is used to report local connection counts and to detect the
|
|
// shutting-down state (which triggers a hard 503). The redisClient may be nil
|
|
// in DevMode; the nil case is reported as degraded rather than failing the
|
|
// probe.
|
|
type Readyzer struct {
|
|
hub *hub.Hub
|
|
redis *redisclient.Client
|
|
instance string
|
|
service string
|
|
}
|
|
|
|
// NewReadyzer constructs a Readyzer. redis may be nil; the resulting probe
|
|
// will mark the missing dependency as degraded.
|
|
func NewReadyzer(h *hub.Hub, r *redisclient.Client, service, instanceID string) *Readyzer {
|
|
return &Readyzer{
|
|
hub: h,
|
|
redis: r,
|
|
instance: instanceID,
|
|
service: service,
|
|
}
|
|
}
|
|
|
|
// Handler is the gin.HandlerFunc for GET /readyz.
|
|
func (rz *Readyzer) Handler(c *gin.Context) {
|
|
// Hard failure: Hub is shutting down — reject new traffic.
|
|
if rz.hub.IsClosing() {
|
|
c.JSON(http.StatusServiceUnavailable, readyzResponse{
|
|
Status: "shutting_down",
|
|
Service: rz.service,
|
|
InstanceID: rz.instance,
|
|
Degraded: true,
|
|
Connections: rz.hub.ActiveConnections(),
|
|
Users: rz.hub.UserCount(),
|
|
Dependencies: map[string]*dependencyStatus{
|
|
"hub": {Ok: false, Error: "hub is closing"},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
deps := make(map[string]*dependencyStatus, 1)
|
|
degraded := false
|
|
|
|
// Redis probe (soft failure).
|
|
if rz.redis == nil {
|
|
deps["redis"] = &dependencyStatus{Ok: false, Error: "not configured"}
|
|
degraded = true
|
|
} else {
|
|
deps["redis"] = probeRedis(rz.redis)
|
|
if !deps["redis"].Ok {
|
|
degraded = true
|
|
}
|
|
}
|
|
|
|
// Always 200 (unless shutting down) per ISSUE-058/006 soft-failure rule.
|
|
c.JSON(http.StatusOK, readyzResponse{
|
|
Status: statusText(degraded),
|
|
Service: rz.service,
|
|
InstanceID: rz.instance,
|
|
Degraded: degraded,
|
|
Connections: rz.hub.ActiveConnections(),
|
|
Users: rz.hub.UserCount(),
|
|
Dependencies: deps,
|
|
})
|
|
}
|
|
|
|
// statusText returns "ok" when healthy, "degraded" when at least one
|
|
// dependency is unhealthy.
|
|
func statusText(degraded bool) string {
|
|
if degraded {
|
|
return "degraded"
|
|
}
|
|
return "ok"
|
|
}
|
|
|
|
// probeRedis issues a PING with a short timeout and reports latency.
|
|
func probeRedis(r *redisclient.Client) *dependencyStatus {
|
|
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
|
|
defer cancel()
|
|
start := time.Now()
|
|
if err := r.Ping(ctx); err != nil {
|
|
return &dependencyStatus{Ok: false, Error: err.Error()}
|
|
}
|
|
return &dependencyStatus{Ok: true, Latency: time.Since(start).Milliseconds()}
|
|
}
|