// Command push-gateway is the Edu platform's real-time WebSocket 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. // // 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) // 6. redisclient.New + SetHub + RebuildPresenceOnStartup (ISSUE-058) // 7. redisclient.SubscribeAll (cross-instance Pub/Sub fanout) // 8. kafkaconsumer.New + goroutine Run (edu.notification.requested) // 9. ws.NewHandler (JWT RS256, X-Internal-Token, WebSocket upgrade) // 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) package main import ( "context" "errors" "net/http" "os" "os/signal" "syscall" "time" "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/ws" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) 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. const shutdownTimeout = 10 * time.Second func main() { cfg := config.Load() // 1. Structured logger (slog). logger := observability.InitLogger(cfg.DevMode) logger.Info("push-gateway starting", "dev_mode", cfg.DevMode, "instance_id", cfg.InstanceID, "port", cfg.Port, "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). tracerShutdown := observability.InitTracer(serviceName, cfg.OTLPEndpoint) defer tracerShutdown() // 3. Prometheus metrics. metrics := observability.NewMetrics() // 4. Hub: in-memory connection registry. h := hub.NewHub(cfg.MaxConnsPerUser) // 5. Redis client (optional in DevMode). When present, wire presence // hooks to the Hub and rebuild the online SET (ISSUE-058). var redisClient *redisclient.Client if cfg.RedisURL != "" { rc, err := redisclient.New(cfg.RedisURL, cfg.InstanceID, metrics) if err != nil { logger.Error("redis connect failed; running without cross-instance fanout", "err", err, "url", cfg.RedisURL) } else { redisClient = rc redisClient.SetHub(h) rebuildCtx, rebuildCancel := context.WithTimeout(context.Background(), 5*time.Second) if err := redisClient.RebuildPresenceOnStartup(rebuildCtx); err != nil { logger.Warn("redis presence rebuild failed (continuing)", "err", err) } rebuildCancel() logger.Info("redis presence synced", "url", cfg.RedisURL) } } else { logger.Warn("REDIS_URL empty; cross-instance fanout disabled") } // 6. Redis Pub/Sub subscriber (cross-instance message fanout). var pubsubCancel func() error if redisClient != nil { subCtx, subCancel := context.WithCancel(context.Background()) defer subCancel() var err error pubsubCancel, err = redisClient.SubscribeAll(subCtx) if err != nil { logger.Error("redis SubscribeAll failed; cross-instance fanout disabled", "err", err) } else { logger.Info("redis pubsub subscriber active") } } // 7. Kafka consumer (edu.notification.requested). 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. wsHandler := ws.NewHandler(h, cfg, redisClient, metrics) // 9. /readyz probe (soft failure per ARB-015 §17.4). readyzer := health.NewReadyzer(h, redisClient, kafkaConsumer, serviceName, cfg.InstanceID) // 10. Gin router. gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.Use(otelgin.Middleware(serviceName)) // Liveness (no auth, no dependency checks). r.GET("/healthz", health.Healthz(serviceName)) // Readiness (soft failure on Redis/Kafka). r.GET("/readyz", readyzer.Handler) // Prometheus metrics. r.GET("/metrics", gin.WrapH(promhttp.Handler())) // WebSocket upgrade (JWT auth via query ?token= or Authorization header). r.GET("/ws", wsHandler.HandleWebSocket) // Internal HTTP APIs consumed by msg (X-Internal-Token auth). internal := r.Group("/internal") internal.POST("/push", wsHandler.PushHandler) internal.POST("/broadcast", wsHandler.BroadcastHandler) internal.GET("/online/:userID", wsHandler.OnlineHandler) srv := &http.Server{ Addr: ":" + cfg.Port, Handler: r, ReadTimeout: 10 * time.Second, WriteTimeout: 0, // WebSocket connections are long-lived; no write timeout. } // 11. Start HTTP server. go func() { logger.Info("http server listening", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("http server fatal error", "err", err) os.Exit(1) } }() // 12. Wait for termination signal. quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) sig := <-quit logger.Info("shutdown signal received", "signal", sig.String()) // 13. Graceful shutdown sequence (see package doc). shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) defer shutdownCancel() // 13a. Hub.CloseAll sends close frame 1001 to every live connection. h.CloseAll() logger.Info("hub closeAll complete; draining connections", "active_connections", h.ActiveConnections()) // 13b. Stop accepting new HTTP requests; drain in-flight. 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. if pubsubCancel != nil { if err := pubsubCancel(); err != nil { logger.Warn("redis pubsub cancel error", "err", err) } } if redisClient != nil { if err := redisClient.Close(); err != nil { logger.Warn("redis close error", "err", err) } } logger.Info("push-gateway shutdown complete") }