338 lines
9.7 KiB
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)
|
|
}
|
|
}
|