Files
Edu/services/push-gateway/internal/hub/hub.go

338 lines
9.7 KiB
Go

// Package hub manages the in-memory WebSocket connection pool for push-gateway.
//
// Hub is the single process-wide registry of all live WebSocket connections,
// indexed by userID -> connID -> *Connection. Each Connection owns a send
// channel consumed by a dedicated writer goroutine; Hub exposes Register /
// Unregister / SendToUser / Broadcast operations.
//
// The Hub also tracks per-user connection counts to enforce the
// MaxConnectionsPerUser limit (default 5) and provides CloseAll for graceful
// shutdown. Cross-instance presence and message fanout are delegated to a
// RedisClient (see internal/redis), keeping Hub free of any persistence layer.
package hub
import (
"errors"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/google/uuid"
)
// ErrTooManyConnections is returned by Register when the per-user connection
// cap has been reached. Callers should translate this into a 429 /
// PUSH_TOO_MANY_CONNECTIONS response or a close frame with code 1008.
var ErrTooManyConnections = errors.New("too many connections per user")
// ErrHubClosing is returned by Register when the Hub is shutting down and no
// new connections are accepted.
var ErrHubClosing = errors.New("hub is closing")
// sendBufferSize is the per-connection send channel capacity. A full channel
// triggers message drop with metric increment (see Send).
const sendBufferSize = 64
// Connection wraps a single WebSocket connection together with its asynchronous
// send channel and lifecycle metadata.
type Connection struct {
UserID string
ConnID string
conn *websocket.Conn
send chan []byte
closeOnce sync.Once
closed bool
mu sync.Mutex
lastPing time.Time
}
// Send delivers message to the connection's writer goroutine. When the send
// channel is full the message is dropped (non-blocking) and the caller is
// notified via the returned false value so it can increment the
// push_gateway_messages_dropped_total{reason="channel_full"} metric.
func (c *Connection) Send(message []byte) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return false
}
select {
case c.send <- message:
return true
default:
return false
}
}
// Outgoing exposes the send channel for the writer goroutine. The channel is
// closed by Close, terminating the consumer loop.
func (c *Connection) Outgoing() <-chan []byte {
return c.send
}
// Close shuts down the connection's send channel exactly once. Subsequent
// Send calls become no-ops.
func (c *Connection) Close() {
c.closeOnce.Do(func() {
c.mu.Lock()
c.closed = true
c.mu.Unlock()
close(c.send)
})
}
// LastPing returns the most recent heartbeat timestamp.
func (c *Connection) LastPing() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.lastPing
}
// TouchPing updates the last heartbeat timestamp to now.
func (c *Connection) TouchPing() {
c.mu.Lock()
defer c.mu.Unlock()
c.lastPing = time.Now()
}
// Conn returns the underlying gorilla websocket.Conn for control-frame
// operations (Ping/Pong/Close). Used by the reader goroutine.
func (c *Connection) Conn() *websocket.Conn {
return c.conn
}
// Hub is the process-wide registry of live WebSocket connections.
//
// clients maps userID -> connID -> *Connection so a single user may hold
// multiple simultaneous connections (multi-device). counters caches the
// per-user connection count to make the MaxConnectionsPerUser check O(1).
type Hub struct {
mu sync.RWMutex
clients map[string]map[string]*Connection
counters map[string]int
closing bool
// maxConnsPerUser caps the number of simultaneous connections per user.
maxConnsPerUser int
// onRegister / onUnregister are optional hooks used by the Redis presence
// layer to maintain the cross-instance online SET. They are invoked outside
// the Hub lock to avoid blocking the registry on Redis round-trips.
onRegister func(userID, connID string)
onUnregister func(userID, connID string)
}
// NewHub returns a Hub with the given per-user connection cap.
func NewHub(maxConnsPerUser int) *Hub {
if maxConnsPerUser <= 0 {
maxConnsPerUser = 5
}
return &Hub{
clients: make(map[string]map[string]*Connection),
counters: make(map[string]int),
maxConnsPerUser: maxConnsPerUser,
}
}
// SetPresenceHooks registers callbacks invoked after Register / Unregister.
// Used by the Redis presence layer to keep edu:push:online:<userID> in sync.
func (h *Hub) SetPresenceHooks(onRegister, onUnregister func(userID, connID string)) {
h.mu.Lock()
defer h.mu.Unlock()
h.onRegister = onRegister
h.onUnregister = onUnregister
}
// ActiveConnections returns the total number of live connections across all
// users. Exposed via /healthz and the push_gateway_active_connections gauge.
func (h *Hub) ActiveConnections() int {
h.mu.RLock()
defer h.mu.RUnlock()
count := 0
for _, conns := range h.clients {
count += len(conns)
}
return count
}
// UserCount returns the number of distinct online users (used by /healthz).
func (h *Hub) UserCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
// HasUser reports whether at least one local connection exists for userID.
// Used by the /internal/push path to decide between local delivery and Redis
// Pub/Sub fanout.
func (h *Hub) HasUser(userID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
conns, ok := h.clients[userID]
return ok && len(conns) > 0
}
// IsClosing reports whether CloseAll has been invoked. The /readyz probe uses
// this to return 503 during graceful shutdown so the load balancer stops
// sending new WebSocket upgrades while existing connections drain.
func (h *Hub) IsClosing() bool {
h.mu.RLock()
defer h.mu.RUnlock()
return h.closing
}
// Register adds a new connection for userID. Returns ErrTooManyConnections
// when the per-user cap is exceeded and ErrHubClosing when the Hub is shutting
// down. The returned Connection must be Unregistered by the caller on close.
func (h *Hub) Register(userID string, conn *websocket.Conn) (*Connection, error) {
h.mu.Lock()
if h.closing {
h.mu.Unlock()
return nil, ErrHubClosing
}
if h.counters[userID] >= h.maxConnsPerUser {
h.mu.Unlock()
return nil, ErrTooManyConnections
}
connID := uuid.NewString()
c := &Connection{
UserID: userID,
ConnID: connID,
conn: conn,
send: make(chan []byte, sendBufferSize),
lastPing: time.Now(),
}
if h.clients[userID] == nil {
h.clients[userID] = make(map[string]*Connection)
}
h.clients[userID][connID] = c
h.counters[userID]++
onRegister := h.onRegister
h.mu.Unlock()
if onRegister != nil {
onRegister(userID, connID)
}
return c, nil
}
// Unregister removes a connection and closes its send channel. Safe to call
// multiple times for the same Connection.
func (h *Hub) Unregister(c *Connection) {
h.mu.Lock()
conns, ok := h.clients[c.UserID]
if !ok {
h.mu.Unlock()
return
}
if _, exists := conns[c.ConnID]; exists {
delete(conns, c.ConnID)
h.counters[c.UserID]--
if h.counters[c.UserID] <= 0 {
delete(h.counters, c.UserID)
}
if len(conns) == 0 {
delete(h.clients, c.UserID)
}
}
onUnregister := h.onUnregister
h.mu.Unlock()
c.Close()
if onUnregister != nil {
onUnregister(c.UserID, c.ConnID)
}
}
// SendToUser delivers message to every local connection of userID. Returns
// the number of connections the message was successfully queued to. A zero
// return value signals "user not online locally" so the caller can fall back
// to Redis Pub/Sub fanout.
func (h *Hub) SendToUser(userID string, message []byte) int {
h.mu.RLock()
conns := h.clients[userID]
// Snapshot the slice under the read lock to avoid holding it during Send.
snapshot := make([]*Connection, 0, len(conns))
for _, c := range conns {
snapshot = append(snapshot, c)
}
h.mu.RUnlock()
delivered := 0
for _, c := range snapshot {
if c.Send(message) {
delivered++
}
}
return delivered
}
// Broadcast delivers message to every live connection in this instance.
// Cross-instance broadcast is handled separately via Redis Pub/Sub.
func (h *Hub) Broadcast(message []byte) int {
h.mu.RLock()
// Count inline: calling ActiveConnections() here would re-enter RLock
// while we already hold it, which can deadlock if a writer is waiting.
total := 0
for _, conns := range h.clients {
total += len(conns)
}
snapshot := make([]*Connection, 0, total)
for _, conns := range h.clients {
for _, c := range conns {
snapshot = append(snapshot, c)
}
}
h.mu.RUnlock()
delivered := 0
for _, c := range snapshot {
if c.Send(message) {
delivered++
}
}
return delivered
}
// CloseAll marks the Hub as closing (rejecting further Register calls) and
// sends a close frame to every live connection. Used during graceful shutdown.
// The caller should wait for connections to drain after CloseAll returns.
func (h *Hub) CloseAll() {
h.mu.Lock()
h.closing = true
// Count inline instead of calling ActiveConnections() to avoid reentrant
// RLock while holding the write lock (Go's sync.RWMutex is non-reentrant).
total := 0
for _, conns := range h.clients {
total += len(conns)
}
all := make([]*Connection, 0, total)
for _, conns := range h.clients {
for _, c := range conns {
all = append(all, c)
}
}
h.mu.Unlock()
for _, c := range all {
// Send a graceful close frame (1001 going away) and close the send
// channel so the writer goroutine exits.
_ = c.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down"),
time.Now().Add(2*time.Second),
)
c.Close()
}
}
// ForEachUser iterates over all locally online userIDs. Used by the Redis
// presence layer to rebuild the online SET on startup (ISSUE-058).
func (h *Hub) ForEachUser(fn func(userID string)) {
h.mu.RLock()
defer h.mu.RUnlock()
for userID := range h.clients {
fn(userID)
}
}