// Package kafkaconsumer subscribes to the edu.notification.requested topic // (ISSUE-053 final topic name) on behalf of push-gateway. Each consumed // NotificationRequested event is dispatched to the Hub for delivery to online // clients. At-least-once delivery is enforced via manual commit; idempotency // is provided by Redis SETNX on event_id (TTL 24h). // // Topic naming follows the G16 rule (edu...); the // previous abstract name edu.notification.events / edu.msg.notification.events // is deprecated (see president-final-rulings §1.5). package kafkaconsumer import ( "context" "encoding/json" "errors" "fmt" "io" "time" "github.com/edu-cloud/push-gateway/internal/hub" "github.com/edu-cloud/push-gateway/internal/observability" "github.com/edu-cloud/push-gateway/internal/redisclient" "github.com/segmentio/kafka-go" ) // MaxRetries is the per-message retry count before sending to the dead-letter // topic. After MaxRetries failures the message is committed (skipped) and // logged; a future P6 enhancement can republish to a DLQ topic. const MaxRetries = 3 // NotificationRequested is the message shape produced by msg (ai10). Field // names mirror the JSON wire format produced by msg's Outbox publisher. type NotificationRequested struct { EventID string `json:"event_id"` UserID string `json:"user_id"` EventType string `json:"event_type"` Channel string `json:"channel"` Title string `json:"title"` Content string `json:"content"` Data json.RawMessage `json:"data"` Broadcast bool `json:"broadcast"` OccurredAt int64 `json:"occurred_at"` } // Consumer wraps a kafka.Reader for the notification topic. type Consumer struct { reader *kafka.Reader hub *hub.Hub redis *redisclient.Client metrics *observability.Metrics topic string groupID string dlqTopic string } // Config configures the Kafka consumer. type Config struct { Brokers []string Topic string GroupID string DLQTopic string } // New creates a Consumer for the configured topic and group. func New(cfg Config, h *hub.Hub, r *redisclient.Client, m *observability.Metrics) *Consumer { if cfg.DLQTopic == "" { cfg.DLQTopic = cfg.Topic + ".dlq" } reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: cfg.Brokers, Topic: cfg.Topic, GroupID: cfg.GroupID, MinBytes: 1, MaxBytes: 10 * 1024 * 1024, CommitInterval: 1 * time.Second, // periodic background commit StartOffset: kafka.LastOffset, // skip historical backlog on first start }) return &Consumer{ reader: reader, hub: h, redis: r, metrics: m, topic: cfg.Topic, groupID: cfg.GroupID, dlqTopic: cfg.DLQTopic, } } // Run blocks until ctx is canceled, consuming messages and dispatching them. // Errors are logged but do not stop the consumer unless ctx is canceled. func (c *Consumer) Run(ctx context.Context) error { observability.Logger().Info("kafka consumer starting", "topic", c.topic, "group", c.groupID, "brokers", c.reader.Config().Brokers) for { select { case <-ctx.Done(): return ctx.Err() default: } msg, err := c.reader.ReadMessage(ctx) if err != nil { if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { return nil } observability.Logger().Warn("kafka read failed", "err", err) select { case <-ctx.Done(): return nil case <-time.After(2 * time.Second): } continue } c.metrics.IncKafkaConsumed(c.topic, fmt.Sprintf("%d", msg.Partition)) c.processMessage(ctx, msg) } } // Close releases the underlying reader. Should be called after Run returns. func (c *Consumer) Close() error { return c.reader.Close() } // HealthCheck verifies that the Kafka broker is reachable. Used by /readyz as // a soft-failure probe: a failing check marks the consumer as degraded but // does not return 503 (per ARB-015 §17.4 / ISSUE-058). // // The check issues a metadata request for the consumer topic; on success the // broker is considered reachable. This is cheaper than a Lag() call which // requires partition assignment to have completed. func (c *Consumer) HealthCheck(ctx context.Context) error { conn, err := kafka.DialContext(ctx, "tcp", c.reader.Config().Brokers[0]) if err != nil { return fmt.Errorf("kafka: dial broker: %w", err) } defer conn.Close() partitions, err := conn.ReadPartitions(c.topic) if err != nil { return fmt.Errorf("kafka: read partitions: %w", err) } if len(partitions) == 0 { return fmt.Errorf("kafka: topic %q has no partitions", c.topic) } return nil } // Topic returns the configured topic name. Exposed for diagnostics. func (c *Consumer) Topic() string { return c.topic } // GroupID returns the configured consumer group name. func (c *Consumer) GroupID() string { return c.groupID } // processMessage handles a single Kafka message with retry and idempotency. func (c *Consumer) processMessage(ctx context.Context, msg kafka.Message) { var event NotificationRequested if err := json.Unmarshal(msg.Value, &event); err != nil { observability.Logger().Warn("kafka: invalid message payload", "topic", c.topic, "partition", msg.Partition, "offset", msg.Offset, "err", err) c.metrics.IncPushed("notification.requested", "invalid") // Commit to skip the poison message. _ = c.reader.CommitMessages(ctx, msg) return } // Idempotency: skip already-processed events by event_id. fresh, err := c.redis.DedupEventId(ctx, event.EventID) if err != nil { observability.Logger().Warn("kafka: dedup check failed, processing anyway", "event_id", event.EventID, "err", err) } if !fresh { observability.Logger().Debug("kafka: duplicate event skipped", "event_id", event.EventID) c.metrics.IncPushed("notification.requested", "duplicate") _ = c.reader.CommitMessages(ctx, msg) return } // Retry up to MaxRetries on dispatch failure. for attempt := 1; attempt <= MaxRetries; attempt++ { if err := c.dispatch(ctx, event); err == nil { c.metrics.IncPushed("notification.requested", "delivered") _ = c.reader.CommitMessages(ctx, msg) return } else if attempt < MaxRetries { observability.Logger().Warn("kafka: dispatch retry", "event_id", event.EventID, "attempt", attempt, "err", err) time.Sleep(time.Duration(attempt) * time.Second) } } // MaxRetries exhausted: send to DLQ (best-effort) and commit. observability.Logger().Error("kafka: dispatch failed after retries, sending to DLQ", "event_id", event.EventID, "topic", c.topic) c.sendToDLQ(ctx, msg.Value) c.metrics.IncPushed("notification.requested", "dlq") _ = c.reader.CommitMessages(ctx, msg) } // dispatch delivers the event to local Hub connections and, when the user is // not local, asks Redis to fan out via Pub/Sub. func (c *Consumer) dispatch(ctx context.Context, event NotificationRequested) error { payload, err := json.Marshal(map[string]any{ "type": "message", "event": event.EventType, "data": event.Data, "timestamp": time.Now().UTC().Format(time.RFC3339), }) if err != nil { return fmt.Errorf("marshal payload: %w", err) } if event.Broadcast { // Broadcast: deliver locally + publish to other instances. c.hub.Broadcast(payload) return c.redis.PublishBroadcast(ctx, redisclient.CrossInstanceMessage{ Event: event.EventType, Data: event.Data, }) } // Directed: try local first, fall back to Redis Pub/Sub. if c.hub.HasUser(event.UserID) { if c.hub.SendToUser(event.UserID, payload) > 0 { return nil } } return c.redis.PublishUser(ctx, event.UserID, redisclient.CrossInstanceMessage{ Event: event.EventType, Data: event.Data, }) } // sendToDLQ publishes a raw message to the dead-letter topic. Best-effort; // failure is logged but does not block commit. func (c *Consumer) sendToDLQ(ctx context.Context, value []byte) { conn, err := kafka.DialLeader(ctx, "tcp", c.reader.Config().Brokers[0], c.dlqTopic, 0) if err != nil { observability.Logger().Error("kafka: DLQ dial failed", "topic", c.dlqTopic, "err", err) return } defer conn.Close() _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) _, _ = conn.WriteMessages(kafka.Message{Value: value}) }