feat(push-gateway,msg): redis pubsub backplane for real-time notifications

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
This commit is contained in:
SpecialX
2026-07-15 01:28:55 +08:00
parent a75527be80
commit 6af1aa0d82
14 changed files with 402 additions and 484 deletions

View File

@@ -0,0 +1,217 @@
// Package sse implements the Server-Sent Events endpoint for real-time
// notification delivery.
//
// M7 (ADR-040): The SSE endpoint replaces direct Kafka mounting with a
// Redis Pub/Sub backplane. Each SSE connection dynamically subscribes to
// the Redis channel `user:{userId}:notify` for the authenticated user.
// When the user disconnects, the subscription is released. This per-connection
// subscription model means each realtime-gateway instance only holds Redis
// subscriptions for users actively connected to it, enabling horizontal
// scaling behind a load balancer.
//
// Authentication:
// - JWT RS256 validated via shared-go/jwks (caching JWKS from iam
// /.well-known/jwks.json). DevMode accepts the literal "dev-token"
// returning a synthetic dev-user subject.
//
// SSE format: `data: {json}\n\n` (per the HTML5 Server-Sent Events spec).
// Heartbeat comments (`: heartbeat\n\n`) are sent every 30s to keep the
// connection alive through proxies.
package sse
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/edu-cloud/push-gateway/internal/config"
"github.com/edu-cloud/push-gateway/internal/observability"
"github.com/edu-cloud/push-gateway/internal/redisclient"
"github.com/edu-cloud/shared-go/jwks"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// heartbeatInterval is how often a comment frame is sent to keep the SSE
// connection alive through proxies that idle-timeout connections.
const heartbeatInterval = 30 * time.Second
// errResponse is the ActionState-shaped error envelope returned for auth
// failures and Redis unavailability.
type errResponse struct {
Success bool `json:"success"`
Error *errBody `json:"error,omitempty"`
}
type errBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
var (
errMissingToken = errors.New("missing token")
errInvalidToken = errors.New("invalid token")
errMissingUserID = errors.New("missing user id")
)
// Handler owns the SSE endpoint for notification delivery.
type Handler struct {
cfg *config.Config
redis *redisclient.Client
jwksFetcher *jwks.Fetcher
metrics *observability.Metrics
}
// NewHandler constructs an SSE Handler. cfg supplies JWT auth configuration;
// redis may be nil in DevMode (SSE returns 503 when Redis is unavailable).
// jwksFetcher is initialized from cfg.JWKSURL when non-empty.
func NewHandler(cfg *config.Config, r *redisclient.Client, m *observability.Metrics) *Handler {
var fetcher *jwks.Fetcher
if cfg.JWKSURL != "" {
fetcher = jwks.NewFetcher(cfg.JWKSURL)
}
return &Handler{
cfg: cfg,
redis: r,
jwksFetcher: fetcher,
metrics: m,
}
}
// HandleNotifications is the GET /sse/notifications endpoint. It upgrades the
// HTTP connection to a persistent SSE stream, subscribing to the user's
// Redis Pub/Sub channel and forwarding messages as SSE data frames.
//
// Lifecycle:
// 1. Authenticate via JWT (query ?token= or Authorization: Bearer header)
// 2. Set SSE headers (Content-Type: text/event-stream, no-cache, etc.)
// 3. SUBSCRIBE to Redis channel `user:{userId}:notify`
// 4. Forward each Redis message as `data: {json}\n\n`
// 5. Send heartbeat comment every 30s
// 6. On client disconnect or context cancel: UNSUBSCRIBE (deferred pubsub.Close)
func (h *Handler) HandleNotifications(c *gin.Context) {
userID, err := h.authenticate(c)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, errResponse{
Success: false,
Error: &errBody{Code: "SSE_UNAUTHORIZED", Message: err.Error()},
})
return
}
if h.redis == nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, errResponse{
Success: false,
Error: &errBody{Code: "SSE_REDIS_UNAVAILABLE", Message: "Redis not configured"},
})
return
}
// SSE response headers.
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering.
// Per-connection Redis subscription to the user's notify channel.
// go-redis PubSub is goroutine-safe per instance; each Subscribe call
// creates an independent PubSub that is closed on connection end.
channel := redisclient.NotifyChannel(userID)
pubsub := h.redis.Redis().Subscribe(c.Request.Context(), channel)
defer pubsub.Close()
msgCh := pubsub.Channel()
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
observability.Logger().Info("sse connection opened",
"user_id", userID, "channel", channel)
c.Stream(func(w io.Writer) bool {
select {
case <-c.Request.Context().Done():
return false
case msg, ok := <-msgCh:
if !ok {
// Redis subscription closed (e.g. Redis connection lost).
observability.Logger().Warn("sse redis subscription closed",
"user_id", userID)
return false
}
// Forward Redis payload as SSE data frame: `data: {json}\n\n`
fmt.Fprintf(w, "data: %s\n\n", msg.Payload)
h.metrics.IncPushed("sse.notification", "delivered")
return true
case <-ticker.C:
// SSE comment frame keeps the connection alive through proxies.
fmt.Fprintf(w, ": heartbeat\n\n")
return true
}
})
observability.Logger().Info("sse connection closed", "user_id", userID)
}
// authenticate validates the JWT token. Production uses RS256 via shared-go/jwks;
// DevMode accepts the literal "dev-token" string. The token may be passed via
// the ?token= query parameter or the Authorization: Bearer header.
func (h *Handler) authenticate(c *gin.Context) (string, error) {
tokenStr := c.Query("token")
if tokenStr == "" {
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
tokenStr = strings.TrimPrefix(auth, "Bearer ")
}
}
if tokenStr == "" {
return "", errMissingToken
}
// DevMode shortcut: dev-token maps to a synthetic local user.
if h.cfg.DevMode && tokenStr == "dev-token" {
return "dev-user", nil
}
// RS256 via shared-go/jwks (preferred, requires iam /.well-known/jwks.json).
if h.jwksFetcher != nil {
claims, err := h.jwksFetcher.ValidateToken(tokenStr)
if err != nil {
return "", errInvalidToken
}
if claims.UserID == "" {
return "", errMissingUserID
}
return claims.UserID, nil
}
// DevMode without JWKS: fall back to HS256 with the configured secret.
if h.cfg.DevMode && h.cfg.JWTSecret != "" {
return validateHS256(tokenStr, h.cfg.JWTSecret)
}
return "", errInvalidToken
}
// validateHS256 parses and validates an HS256 JWT signed with secret. Used
// only in DevMode when no JWKS endpoint is configured (local integration
// tests). Returns the user_id claim on success.
func validateHS256(tokenStr, secret string) (string, error) {
claims := &jwks.Claims{}
parsed, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("sse: unexpected signing method %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return "", errInvalidToken
}
if !parsed.Valid {
return "", errInvalidToken
}
if claims.UserID == "" {
return "", errMissingUserID
}
return claims.UserID, nil
}