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:
@@ -1,28 +1,41 @@
|
||||
// Command push-gateway is the Edu platform's real-time WebSocket push gateway.
|
||||
// Command push-gateway is the Edu platform's real-time push gateway.
|
||||
//
|
||||
// It maintains per-user WebSocket connections, delivers directed and broadcast
|
||||
// messages from msg (via HTTP /internal/* APIs and Kafka), and synchronizes
|
||||
// online presence across instances via Redis Pub/Sub.
|
||||
// M7 (ADR-040): The gateway no longer mounts Kafka directly. Real-time push
|
||||
// is routed through a Redis Pub/Sub backplane. The msg service publishes
|
||||
// notifications to Redis channel `user:{userId}:notify` after Kafka
|
||||
// consumption (persistence, desensitization). This gateway dynamically
|
||||
// subscribes to the user's Redis channel when an SSE connection is opened,
|
||||
// and unsubscribes on disconnect.
|
||||
//
|
||||
// Endpoints:
|
||||
// - GET /sse/notifications: SSE stream for real-time notifications (JWT auth,
|
||||
// per-connection Redis subscription). M7 preferred path.
|
||||
// - GET /ws: WebSocket upgrade (JWT auth, legacy path with global Redis
|
||||
// PSubscribe fanout for backwards compatibility).
|
||||
// - POST /internal/push, /internal/broadcast: internal HTTP APIs (X-Internal-Key
|
||||
// auth, consumed by msg for legacy WebSocket fanout).
|
||||
// - GET /internal/online/:userID: presence query.
|
||||
// - GET /healthz, /readyz: health probes.
|
||||
// - GET /metrics: Prometheus metrics.
|
||||
//
|
||||
// Startup sequence:
|
||||
// 1. config.Load (env vars, DevMode detection)
|
||||
// 2. observability.InitLogger (slog JSON/text)
|
||||
// 3. observability.InitTracer (shared-go/tracer → OTLP)
|
||||
// 4. observability.NewMetrics (Prometheus)
|
||||
// 5. hub.NewHub (in-memory connection registry)
|
||||
// 5. hub.NewHub (in-memory connection registry for WebSocket)
|
||||
// 6. redisclient.New + SetHub + RebuildPresenceOnStartup (ISSUE-058)
|
||||
// 7. redisclient.SubscribeAll (cross-instance Pub/Sub fanout)
|
||||
// 8. kafkaconsumer.New + goroutine Run (edu.notify.notification.sent, ARB-013)
|
||||
// 9. ws.NewHandler (JWT RS256, X-Internal-Key, WebSocket upgrade)
|
||||
// 7. redisclient.SubscribeAll (cross-instance Pub/Sub fanout for WebSocket)
|
||||
// 8. ws.NewHandler (JWT RS256, X-Internal-Key, WebSocket upgrade)
|
||||
// 9. sse.NewHandler (JWT RS256, per-connection Redis subscription)
|
||||
// 10. health.NewReadyzer (/readyz soft-failure probe)
|
||||
// 11. gin router + http.Server
|
||||
//
|
||||
// Graceful shutdown (SIGINT/SIGTERM):
|
||||
// 1. Hub.CloseAll (send close frame 1001 to every live connection)
|
||||
// 2. http.Server.Shutdown (stop accepting new HTTP/WebSocket requests)
|
||||
// 3. Kafka consumer goroutine cancel + reader.Close
|
||||
// 4. Redis Pub/Sub cancel + client.Close
|
||||
// 5. Tracer shutdown (flush pending spans)
|
||||
// 1. Hub.CloseAll (send close frame 1001 to every live WebSocket connection)
|
||||
// 2. http.Server.Shutdown (stop accepting new HTTP/SSE/WebSocket requests)
|
||||
// 3. Redis Pub/Sub cancel + client.Close
|
||||
// 4. Tracer shutdown (flush pending spans)
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -37,9 +50,9 @@ import (
|
||||
"github.com/edu-cloud/push-gateway/internal/config"
|
||||
"github.com/edu-cloud/push-gateway/internal/health"
|
||||
"github.com/edu-cloud/push-gateway/internal/hub"
|
||||
"github.com/edu-cloud/push-gateway/internal/kafkaconsumer"
|
||||
"github.com/edu-cloud/push-gateway/internal/observability"
|
||||
"github.com/edu-cloud/push-gateway/internal/redisclient"
|
||||
"github.com/edu-cloud/push-gateway/internal/sse"
|
||||
"github.com/edu-cloud/push-gateway/internal/ws"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
@@ -50,7 +63,8 @@ const serviceName = "push-gateway"
|
||||
|
||||
// shutdownTimeout bounds the graceful shutdown of the HTTP server. After this
|
||||
// window in-flight requests are forcibly closed. WebSocket long-poll
|
||||
// connections are drained by Hub.CloseAll before this timer starts.
|
||||
// connections and SSE streams are drained by Hub.CloseAll before this timer
|
||||
// starts; SSE streams are drained by http.Server.Shutdown.
|
||||
const shutdownTimeout = 10 * time.Second
|
||||
|
||||
func main() {
|
||||
@@ -65,8 +79,6 @@ func main() {
|
||||
"max_conns_per_user", cfg.MaxConnsPerUser,
|
||||
"jwks_url", cfg.JWKSURL,
|
||||
"redis_url", cfg.RedisURL,
|
||||
"kafka_brokers", cfg.KafkaBrokers,
|
||||
"kafka_topic", cfg.KafkaNotificationTopic,
|
||||
)
|
||||
|
||||
// 2. OpenTelemetry tracer (best-effort).
|
||||
@@ -76,7 +88,7 @@ func main() {
|
||||
// 3. Prometheus metrics.
|
||||
metrics := observability.NewMetrics()
|
||||
|
||||
// 4. Hub: in-memory connection registry.
|
||||
// 4. Hub: in-memory connection registry (for WebSocket legacy path).
|
||||
h := hub.NewHub(cfg.MaxConnsPerUser)
|
||||
|
||||
// 5. Redis client (optional in DevMode). When present, wire presence
|
||||
@@ -98,10 +110,10 @@ func main() {
|
||||
logger.Info("redis presence synced", "url", cfg.RedisURL)
|
||||
}
|
||||
} else {
|
||||
logger.Warn("REDIS_URL empty; cross-instance fanout disabled")
|
||||
logger.Warn("REDIS_URL empty; SSE and cross-instance fanout disabled")
|
||||
}
|
||||
|
||||
// 6. Redis Pub/Sub subscriber (cross-instance message fanout).
|
||||
// 6. Redis Pub/Sub subscriber (cross-instance message fanout for WebSocket).
|
||||
var pubsubCancel func() error
|
||||
if redisClient != nil {
|
||||
subCtx, subCancel := context.WithCancel(context.Background())
|
||||
@@ -116,36 +128,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Kafka consumer (edu.notify.notification.sent, ARB-013). Started only
|
||||
// when at least one broker is configured; otherwise the HTTP /internal/push
|
||||
// API is the only delivery channel.
|
||||
var kafkaConsumer *kafkaconsumer.Consumer
|
||||
var kafkaCancel context.CancelFunc
|
||||
if len(cfg.KafkaBrokers) > 0 && cfg.KafkaNotificationTopic != "" {
|
||||
kafkaConsumer = kafkaconsumer.New(kafkaconsumer.Config{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
Topic: cfg.KafkaNotificationTopic,
|
||||
GroupID: cfg.KafkaConsumerGroup,
|
||||
}, h, redisClient, metrics)
|
||||
kafkaCtx, cancel := context.WithCancel(context.Background())
|
||||
kafkaCancel = cancel
|
||||
go func() {
|
||||
logger.Info("kafka consumer starting",
|
||||
"topic", cfg.KafkaNotificationTopic, "group", cfg.KafkaConsumerGroup)
|
||||
if err := kafkaConsumer.Run(kafkaCtx); err != nil &&
|
||||
!errors.Is(err, context.Canceled) {
|
||||
logger.Error("kafka consumer exited with error", "err", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
logger.Warn("KAFKA_BROKERS empty; notification consumption disabled")
|
||||
}
|
||||
|
||||
// 8. WebSocket + internal HTTP handlers.
|
||||
// 7. WebSocket + internal HTTP handlers (legacy path, backwards compat).
|
||||
wsHandler := ws.NewHandler(h, cfg, redisClient, metrics)
|
||||
|
||||
// 8. SSE handler (M7 preferred path: per-connection Redis subscription).
|
||||
sseHandler := sse.NewHandler(cfg, redisClient, metrics)
|
||||
|
||||
// 9. /readyz probe (soft failure per ARB-015 §17.4).
|
||||
readyzer := health.NewReadyzer(h, redisClient, kafkaConsumer, serviceName, cfg.InstanceID)
|
||||
readyzer := health.NewReadyzer(h, redisClient, serviceName, cfg.InstanceID)
|
||||
|
||||
// 10. Gin router.
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -155,10 +145,12 @@ func main() {
|
||||
|
||||
// Liveness (no auth, no dependency checks).
|
||||
r.GET("/healthz", health.Healthz(serviceName))
|
||||
// Readiness (soft failure on Redis/Kafka).
|
||||
// Readiness (soft failure on Redis).
|
||||
r.GET("/readyz", readyzer.Handler)
|
||||
// Prometheus metrics.
|
||||
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
// SSE endpoint (M7 preferred: per-connection Redis subscription).
|
||||
r.GET("/sse/notifications", sseHandler.HandleNotifications)
|
||||
// WebSocket upgrade (JWT auth via query ?token= or Authorization header).
|
||||
r.GET("/ws", wsHandler.HandleWebSocket)
|
||||
// Internal HTTP APIs consumed by msg (X-Internal-Key auth, ARB-013).
|
||||
@@ -171,7 +163,7 @@ func main() {
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: r,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 0, // WebSocket connections are long-lived; no write timeout.
|
||||
WriteTimeout: 0, // SSE and WebSocket connections are long-lived; no write timeout.
|
||||
}
|
||||
|
||||
// 11. Start HTTP server.
|
||||
@@ -193,27 +185,17 @@ func main() {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
|
||||
// 13a. Hub.CloseAll sends close frame 1001 to every live connection.
|
||||
// 13a. Hub.CloseAll sends close frame 1001 to every live WebSocket connection.
|
||||
h.CloseAll()
|
||||
logger.Info("hub closeAll complete; draining connections",
|
||||
"active_connections", h.ActiveConnections())
|
||||
|
||||
// 13b. Stop accepting new HTTP requests; drain in-flight.
|
||||
// 13b. Stop accepting new HTTP requests; drain in-flight (including SSE).
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("http server shutdown error", "err", err)
|
||||
}
|
||||
|
||||
// 13c. Stop Kafka consumer (cancel context + close reader).
|
||||
if kafkaCancel != nil {
|
||||
kafkaCancel()
|
||||
}
|
||||
if kafkaConsumer != nil {
|
||||
if err := kafkaConsumer.Close(); err != nil {
|
||||
logger.Warn("kafka consumer close error", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 13d. Stop Redis Pub/Sub subscriber + close client.
|
||||
// 13c. Stop Redis Pub/Sub subscriber + close client.
|
||||
if pubsubCancel != nil {
|
||||
if err := pubsubCancel(); err != nil {
|
||||
logger.Warn("redis pubsub cancel error", "err", err)
|
||||
|
||||
Reference in New Issue
Block a user