371 lines
12 KiB
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)
|
|
}
|