Files
Edu/services/push-gateway/internal/redisclient/pubsub.go

371 lines
12 KiB
Go

// Package redisclient manages cross-instance presence and message fanout
// using Redis Pub/Sub plus the online-presence SET described in 02 §3.1 and
// §8.4. It implements the ISSUE-058 startup-rebuild mechanism: when a Hub
// starts it clears stale members belonging to its own instanceID from the
// online SET, then re-adds itself for every locally connected user.
//
// Keys / channels:
//
// edu:push:online:<userID> SET (members are instanceID) TTL 60s
// edu:push:session:<userID>:<connID> HASH (instanceID, device, lastPing) TTL 60s
// edu:push:channel:user:<userID> Pub/Sub channel (directed push)
// edu:push:channel:broadcast Pub/Sub channel (broadcast push)
// edu:push:idempotent:<event_id> SETNX key (Kafka dedup) TTL 24h
package redisclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/edu-cloud/push-gateway/internal/hub"
"github.com/edu-cloud/push-gateway/internal/observability"
"github.com/redis/go-redis/v9"
)
// PresenceTTL is the expiry applied to the online-presence SET and per-session
// HASH. The Hub refreshes it on every heartbeat; if a push-gateway instance
// crashes the SET entries expire naturally within this window.
const PresenceTTL = 60 * time.Second
// IdempotentTTL is the dedup window for Kafka event_id (24h).
const IdempotentTTL = 24 * time.Hour
// CrossInstanceMessage is the payload published to the
// edu:push:channel:user:<userID> and edu:push:channel:broadcast channels.
// Subscribers unmarshal it and dispatch to local Hub connections.
type CrossInstanceMessage struct {
Kind string `json:"kind"` // "user" | "broadcast"
UserID string `json:"userId"` // set when Kind == "user"
Event string `json:"event"`
Data json.RawMessage `json:"data"`
Origin string `json:"origin"` // instanceID of publisher (loopback suppression)
TraceID string `json:"traceId,omitempty"`
}
// Client wraps a redis.Client with push-gateway specific presence and
// fanout helpers. The Hub is wired via SetHub so the subscriber can dispatch
// incoming Pub/Sub messages directly to local connections.
type Client struct {
rdb *redis.Client
instanceID string
hub *hub.Hub
metrics *observability.Metrics
}
// New connects to Redis at url and returns a Client ready for presence and
// Pub/Sub operations. The instanceID identifies this process in the online
// SET (see ISSUE-058).
func New(url, instanceID string, m *observability.Metrics) (*Client, error) {
if url == "" {
return nil, errors.New("redisclient: empty URL")
}
opts, err := redis.ParseURL(url)
if err != nil {
return nil, fmt.Errorf("redisclient: parsing URL %q: %w", url, err)
}
rdb := redis.NewClient(opts)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
_ = rdb.Close()
return nil, fmt.Errorf("redisclient: ping failed: %w", err)
}
return &Client{
rdb: rdb,
instanceID: instanceID,
metrics: m,
}, nil
}
// SetHub wires the Hub so the Pub/Sub subscriber can deliver messages locally.
// Also installs presence hooks on the Hub so Register/Unregister keep the
// online SET in sync.
func (c *Client) SetHub(h *hub.Hub) {
c.hub = h
h.SetPresenceHooks(c.onRegister, c.onUnregister)
}
// Redis returns the underlying *redis.Client for direct use by callers that
// need bespoke commands (e.g. SETNX for Kafka idempotency).
func (c *Client) Redis() *redis.Client {
return c.rdb
}
// Close releases the Redis connection.
func (c *Client) Close() error {
return c.rdb.Close()
}
// Ping is a lightweight liveness probe used by /readyz.
func (c *Client) Ping(ctx context.Context) error {
return c.rdb.Ping(ctx).Err()
}
// ----- Presence SET (ISSUE-058) -------------------------------------------
// RebuildPresenceOnStartup clears stale SET entries belonging to this
// instanceID and re-adds the instanceID to edu:push:online:<userID> for every
// locally connected user (ISSUE-058 startup rebuild).
//
// This addresses the "ghost member" problem: when a push-gateway instance
// restarts, its previous instanceID entries may still be present in Redis
// (within the 60s TTL window). Re-adding the same instanceID would be a
// no-op, but if the instance previously crashed without removing members we
// must avoid carrying stale entries that no longer correspond to live
// connections. We clear first, then rebuild from the in-memory Hub state.
//
// 60s inconsistency window: between crash and SET expiry, /internal/push may
// report online:true for a user whose connection is gone; msg must tolerate
// delivered:false by retrying or falling back to offline push.
func (c *Client) RebuildPresenceOnStartup(ctx context.Context) error {
if c.hub == nil {
return errors.New("redisclient: Hub not set")
}
start := time.Now()
c.metrics.IncRedisSetRebuild()
// Clear stale members belonging to this instanceID across all online SETs.
// We scan keys matching edu:push:online:* and SREM our instanceID from each.
iter := c.rdb.Scan(ctx, 0, "edu:push:online:*", 100).Iterator()
cleared := 0
for iter.Next(ctx) {
key := iter.Val()
if n, err := c.rdb.SRem(ctx, key, c.instanceID).Result(); err == nil && n > 0 {
cleared++
}
}
if err := iter.Err(); err != nil {
return fmt.Errorf("redisclient: scanning online SETs: %w", err)
}
// Re-add instanceID for every locally online user.
readded := 0
c.hub.ForEachUser(func(userID string) {
key := onlineKey(userID)
pipe := c.rdb.Pipeline()
pipe.SAdd(ctx, key, c.instanceID)
pipe.Expire(ctx, key, PresenceTTL)
if _, err := pipe.Exec(ctx); err == nil {
readded++
}
})
observability.Logger().Info("redis presence rebuilt",
"cleared", cleared,
"readded", readded,
"duration_ms", time.Since(start).Milliseconds(),
)
return nil
}
// onRegister is the Hub hook invoked after a new connection is registered.
// Adds the instanceID to the user's online SET and refreshes TTL.
func (c *Client) onRegister(userID, connID string) {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
key := onlineKey(userID)
pipe := c.rdb.Pipeline()
pipe.SAdd(ctx, key, c.instanceID)
pipe.Expire(ctx, key, PresenceTTL)
if _, err := pipe.Exec(ctx); err != nil {
observability.Logger().Warn("redis SAdd failed",
"user_id", userID, "err", err)
}
}
// onUnregister is the Hub hook invoked after a connection is removed.
// Removes the instanceID from the SET and deletes the key when empty.
func (c *Client) onUnregister(userID, connID string) {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
key := onlineKey(userID)
// Only remove if this user has no further local connections (Hub already
// removed the connection by the time the hook fires).
if c.hub != nil && !c.hub.HasUser(userID) {
if err := c.rdb.SRem(ctx, key, c.instanceID).Err(); err != nil {
observability.Logger().Warn("redis SRem failed",
"user_id", userID, "err", err)
}
// Best-effort DEL when SET becomes empty.
_ = c.rdb.Del(ctx, key).Err()
}
}
// RefreshPresence extends the online SET TTL on heartbeat. Called by the
// WebSocket reader goroutine when a Ping control frame arrives.
func (c *Client) RefreshPresence(ctx context.Context, userID string) error {
return c.rdb.Expire(ctx, onlineKey(userID), PresenceTTL).Err()
}
// IsOnline reports whether userID has any instance in the online SET.
// Returns false when Redis is unreachable; callers should treat the
// /internal/push response as authoritative for delivery.
func (c *Client) IsOnline(ctx context.Context, userID string) (bool, []string, error) {
members, err := c.rdb.SMembers(ctx, onlineKey(userID)).Result()
if err != nil {
return false, nil, err
}
return len(members) > 0, members, nil
}
// ----- Pub/Sub fanout ------------------------------------------------------
// PublishUser publishes a directed message to edu:push:channel:user:<userID>.
// All push-gateway instances subscribe to this channel; the one holding the
// user will deliver locally, others drop it.
func (c *Client) PublishUser(ctx context.Context, userID string, msg CrossInstanceMessage) error {
msg.Kind = "user"
msg.UserID = userID
msg.Origin = c.instanceID
payload, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("redisclient: marshalling user msg: %w", err)
}
start := time.Now()
if err := c.rdb.Publish(ctx, userChannel(userID), payload).Err(); err != nil {
return fmt.Errorf("redisclient: PUBLISH user: %w", err)
}
c.metrics.ObservePubSubLatency("publish", time.Since(start).Seconds())
return nil
}
// PublishBroadcast publishes a broadcast message to all instances.
func (c *Client) PublishBroadcast(ctx context.Context, msg CrossInstanceMessage) error {
msg.Kind = "broadcast"
msg.Origin = c.instanceID
payload, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("redisclient: marshalling broadcast msg: %w", err)
}
start := time.Now()
if err := c.rdb.Publish(ctx, broadcastChannel(), payload).Err(); err != nil {
return fmt.Errorf("redisclient: PUBLISH broadcast: %w", err)
}
c.metrics.ObservePubSubLatency("publish", time.Since(start).Seconds())
return nil
}
// SubscribeAll subscribes to all per-user channels (pattern) and the
// broadcast channel, dispatching received messages to the local Hub.
// The returned cancel function stops the subscriber; it blocks until the
// goroutine has fully drained.
func (c *Client) SubscribeAll(ctx context.Context) (cancel func() error, err error) {
if c.hub == nil {
return nil, errors.New("redisclient: Hub not set")
}
subCtx, subCancel := context.WithCancel(ctx)
userPubsub := c.rdb.PSubscribe(subCtx, "edu:push:channel:user:*")
broadcastPubsub := c.rdb.Subscribe(subCtx, broadcastChannel())
go c.dispatchLoop(subCtx, userPubsub, "user")
go c.dispatchLoop(subCtx, broadcastPubsub, "broadcast")
return func() error {
subCancel()
if err := userPubsub.Close(); err != nil {
return err
}
return broadcastPubsub.Close()
}, nil
}
// dispatchLoop consumes Pub/Sub messages and dispatches them to the Hub.
// channelType is "user" or "broadcast" for metric labeling.
func (c *Client) dispatchLoop(ctx context.Context, ps *redis.PubSub, channelType string) {
ch := ps.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
start := time.Now()
c.handlePubSubMessage(msg, channelType)
c.metrics.ObservePubSubLatency("subscribe", time.Since(start).Seconds())
}
}
}
// handlePubSubMessage unmarshals the payload and dispatches to the Hub,
// suppressing loopback (messages this instance published itself).
func (c *Client) handlePubSubMessage(msg *redis.Message, channelType string) {
var crossMsg CrossInstanceMessage
if err := json.Unmarshal([]byte(msg.Payload), &crossMsg); err != nil {
observability.Logger().Warn("pubsub: invalid payload",
"channel", msg.Channel, "err", err)
return
}
// Loopback suppression: do not deliver our own publishes.
if crossMsg.Origin == c.instanceID {
return
}
payload, err := json.Marshal(map[string]any{
"type": "message",
"event": crossMsg.Event,
"data": crossMsg.Data,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
return
}
switch crossMsg.Kind {
case "user":
c.hub.SendToUser(crossMsg.UserID, payload)
case "broadcast":
c.hub.Broadcast(payload)
}
}
// ----- Idempotency (Kafka dedup) ------------------------------------------
// DedupEventId performs a SETNX with TTL for Kafka event_id dedup.
// Returns true when the event is fresh (first occurrence), false when it
// has already been consumed.
func (c *Client) DedupEventId(ctx context.Context, eventID string) (bool, error) {
if eventID == "" {
return true, nil
}
ok, err := c.rdb.SetNX(ctx, idempotentKey(eventID), "1", IdempotentTTL).Result()
if err != nil {
return false, err
}
return ok, nil
}
// ----- Key helpers ---------------------------------------------------------
func onlineKey(userID string) string {
return "edu:push:online:" + userID
}
func userChannel(userID string) string {
return "edu:push:channel:user:" + userID
}
func broadcastChannel() string {
return "edu:push:channel:broadcast"
}
func idempotentKey(eventID string) string {
return "edu:push:idempotent:" + eventID
}
// ParseUserChannel extracts the userID from a "edu:push:channel:user:<userID>"
// channel name. Returns empty string when the name does not match.
func ParseUserChannel(channel string) string {
const prefix = "edu:push:channel:user:"
if !strings.HasPrefix(channel, prefix) {
return ""
}
return strings.TrimPrefix(channel, prefix)
}