248 lines
8.1 KiB
Go
248 lines
8.1 KiB
Go
// 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.<domain>.<aggregate>.<action>); 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})
|
|
}
|