package events import ( "bytes" "context" "errors" "fmt" "strings" "sync" "time" "galaxy/gateway/internal/config" "galaxy/gateway/internal/push" "galaxy/gateway/internal/telemetry" "github.com/redis/go-redis/v9" "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) const clientEventReadCount int64 = 128 // ClientEventPublisher accepts decoded client-facing events from the internal // event subscriber. type ClientEventPublisher interface { // Publish fans out event to the currently active push streams. Publish(event push.Event) } // RedisClientEventSubscriber consumes client-facing events from one Redis // Stream and forwards them to the configured publisher. type RedisClientEventSubscriber struct { client *redis.Client stream string pingTimeout time.Duration readBlockTimeout time.Duration publisher ClientEventPublisher logger *zap.Logger metrics *telemetry.Runtime startedOnce sync.Once started chan struct{} } // NewRedisClientEventSubscriber constructs a Redis Stream subscriber that uses // client and forwards decoded client-facing events to publisher. func NewRedisClientEventSubscriber(client *redis.Client, sessionCfg config.SessionCacheRedisConfig, eventsCfg config.ClientEventsRedisConfig, publisher ClientEventPublisher) (*RedisClientEventSubscriber, error) { return NewRedisClientEventSubscriberWithObservability(client, sessionCfg, eventsCfg, publisher, nil, nil) } // NewRedisClientEventSubscriberWithObservability constructs a Redis Stream // subscriber that also records malformed or dropped internal events. The // subscriber does not own the client; the runtime supplies a shared // *redis.Client. func NewRedisClientEventSubscriberWithObservability(client *redis.Client, sessionCfg config.SessionCacheRedisConfig, eventsCfg config.ClientEventsRedisConfig, publisher ClientEventPublisher, logger *zap.Logger, metrics *telemetry.Runtime) (*RedisClientEventSubscriber, error) { if client == nil { return nil, errors.New("new redis client event subscriber: nil redis client") } if sessionCfg.LookupTimeout <= 0 { return nil, errors.New("new redis client event subscriber: lookup timeout must be positive") } if strings.TrimSpace(eventsCfg.Stream) == "" { return nil, errors.New("new redis client event subscriber: stream must not be empty") } if eventsCfg.ReadBlockTimeout <= 0 { return nil, errors.New("new redis client event subscriber: read block timeout must be positive") } if publisher == nil { return nil, errors.New("new redis client event subscriber: nil publisher") } if logger == nil { logger = zap.NewNop() } return &RedisClientEventSubscriber{ client: client, stream: eventsCfg.Stream, pingTimeout: sessionCfg.LookupTimeout, readBlockTimeout: eventsCfg.ReadBlockTimeout, publisher: publisher, logger: logger.Named("client_event_subscriber"), metrics: metrics, started: make(chan struct{}), }, nil } // Run consumes client-facing events until ctx is canceled or Redis returns an // unexpected error. func (s *RedisClientEventSubscriber) Run(ctx context.Context) error { if s == nil || s.client == nil { return errors.New("run redis client event subscriber: nil subscriber") } if ctx == nil { return errors.New("run redis client event subscriber: nil context") } if err := ctx.Err(); err != nil { return err } lastID, err := s.resolveStartID(ctx) if err != nil { return err } s.signalStarted() for { streams, err := s.client.XRead(ctx, &redis.XReadArgs{ Streams: []string{s.stream, lastID}, Count: clientEventReadCount, Block: s.readBlockTimeout, }).Result() switch { case err == nil: for _, stream := range streams { for _, message := range stream.Messages { s.publishMessage(message) lastID = message.ID } } continue case errors.Is(err, redis.Nil): continue case ctx.Err() != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, redis.ErrClosed)): return ctx.Err() case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, redis.ErrClosed): return fmt.Errorf("run redis client event subscriber: %w", err) default: return fmt.Errorf("run redis client event subscriber: %w", err) } } } func (s *RedisClientEventSubscriber) resolveStartID(ctx context.Context) (string, error) { messages, err := s.client.XRevRangeN(ctx, s.stream, "+", "-", 1).Result() switch { case err == nil: case errors.Is(err, redis.Nil): return "0-0", nil default: return "", fmt.Errorf("run redis client event subscriber: resolve stream tail: %w", err) } if len(messages) == 0 { return "0-0", nil } return messages[0].ID, nil } // Shutdown is a no-op kept for App framework compatibility. The blocking // XRead loop terminates when its context is cancelled by the parent runtime, // which also owns and closes the shared Redis client. func (s *RedisClientEventSubscriber) Shutdown(ctx context.Context) error { if ctx == nil { return errors.New("shutdown redis client event subscriber: nil context") } return nil } // Close is a no-op kept for backwards-compatible cleanup wiring; the // subscriber does not own the shared Redis client. func (s *RedisClientEventSubscriber) Close() error { return nil } func (s *RedisClientEventSubscriber) signalStarted() { s.startedOnce.Do(func() { close(s.started) }) } func (s *RedisClientEventSubscriber) publishMessage(message redis.XMessage) { event, err := decodeClientEvent(message.Values) if err != nil { s.logger.Warn("dropped malformed client event", zap.String("stream", s.stream), zap.String("message_id", message.ID), zap.Error(err), ) s.metrics.RecordInternalEventDrop(context.Background(), attribute.String("component", "client_event_subscriber"), attribute.String("reason", "malformed_event"), ) return } s.publisher.Publish(event) } func decodeClientEvent(values map[string]any) (push.Event, error) { requiredKeys := map[string]struct{}{ "user_id": {}, "event_type": {}, "event_id": {}, "payload_bytes": {}, } optionalKeys := map[string]struct{}{ "device_session_id": {}, "request_id": {}, "trace_id": {}, } for key := range values { if _, ok := requiredKeys[key]; ok { continue } if _, ok := optionalKeys[key]; ok { continue } return push.Event{}, fmt.Errorf("decode client event: unsupported field %q", key) } userID, err := requiredStringField(values, "user_id") if err != nil { return push.Event{}, err } eventType, err := requiredStringField(values, "event_type") if err != nil { return push.Event{}, err } eventID, err := requiredStringField(values, "event_id") if err != nil { return push.Event{}, err } payloadBytes, err := requiredBytesField(values, "payload_bytes") if err != nil { return push.Event{}, err } event := push.Event{ UserID: userID, EventType: eventType, EventID: eventID, PayloadBytes: payloadBytes, } if deviceSessionID, ok, err := optionalStringField(values, "device_session_id"); err != nil { return push.Event{}, err } else if ok { event.DeviceSessionID = strings.TrimSpace(deviceSessionID) } if requestID, ok, err := optionalStringField(values, "request_id"); err != nil { return push.Event{}, err } else if ok { event.RequestID = requestID } if traceID, ok, err := optionalStringField(values, "trace_id"); err != nil { return push.Event{}, err } else if ok { event.TraceID = traceID } return event, nil } func requiredBytesField(values map[string]any, field string) ([]byte, error) { value, ok := values[field] if !ok { return nil, fmt.Errorf("decode client event: missing %s", field) } byteValue, err := coerceBytes(value) if err != nil { return nil, fmt.Errorf("decode client event: %s: %w", field, err) } return byteValue, nil } func optionalStringField(values map[string]any, field string) (string, bool, error) { value, ok := values[field] if !ok { return "", false, nil } stringValue, err := coerceString(value) if err != nil { return "", false, fmt.Errorf("decode client event: %s: %w", field, err) } return stringValue, true, nil } func coerceBytes(value any) ([]byte, error) { switch typed := value.(type) { case string: return []byte(typed), nil case []byte: return bytes.Clone(typed), nil default: return nil, fmt.Errorf("unsupported type %T", value) } }