package mq import ( "context" "crypto/tls" "errors" "fmt" "io" "net" "strconv" "strings" "sync" "sync/atomic" "time" kafkago "github.com/segmentio/kafka-go" "github.com/segmentio/kafka-go/sasl" "github.com/segmentio/kafka-go/sasl/plain" ) type KafkaConfig struct { Enabled bool Brokers []string ClientID string GroupID string Username string Password string TLS bool TLSSkipVerify bool StartOffset string MinBytes int MaxBytes int MaxWait time.Duration ConnectTimeout time.Duration ReconnectInterval time.Duration AllowAutoTopicCreation bool } type kafkaSubscription struct { reader *kafkago.Reader cancel context.CancelFunc qos byte handler Handler } type kafkaTopicMetadataError struct { err error } func (e *kafkaTopicMetadataError) Error() string { return e.err.Error() } func (e *kafkaTopicMetadataError) Unwrap() error { return e.err } // Kafka adapts Kafka topics and consumer groups to the shared messaging // contract. QoS 0 commits before dispatch; QoS 1 commits after dispatch. type Kafka struct { opMu sync.Mutex mu sync.RWMutex config KafkaConfig dialer *kafkago.Dialer metadata *kafkago.Client transport *kafkago.Transport bestEffort *kafkago.Writer acknowledged *kafkago.Writer subscriptions map[string]*kafkaSubscription connected atomic.Bool reconnectAt atomic.Int64 closed bool } func NewKafka(config KafkaConfig) (*Kafka, error) { client := &Kafka{subscriptions: make(map[string]*kafkaSubscription)} if !config.Enabled { return client, nil } config = defaultKafkaConfig(config) if err := validateKafkaConfig(config); err != nil { return nil, err } tlsConfig, mechanism := kafkaSecurity(config) dialer := &kafkago.Dialer{ ClientID: config.ClientID, Timeout: config.ConnectTimeout, TLS: tlsConfig, SASLMechanism: mechanism, } transport := &kafkago.Transport{ DialTimeout: config.ConnectTimeout, ClientID: config.ClientID, TLS: tlsConfig, SASL: mechanism, } metadata := &kafkago.Client{ Addr: kafkago.TCP(config.Brokers...), Timeout: config.ConnectTimeout, Transport: transport, } if err := probeKafkaMetadata(context.Background(), metadata, ""); err != nil { transport.CloseIdleConnections() return nil, fmt.Errorf("connect kafka: %w", err) } newWriter := func(acks kafkago.RequiredAcks) *kafkago.Writer { return &kafkago.Writer{ Addr: kafkago.TCP(config.Brokers...), Balancer: &kafkago.LeastBytes{}, RequiredAcks: acks, ReadTimeout: config.ConnectTimeout, WriteTimeout: config.ConnectTimeout, Transport: transport, AllowAutoTopicCreation: config.AllowAutoTopicCreation, } } client.config = config client.dialer = dialer client.metadata = metadata client.transport = transport client.bestEffort = newWriter(kafkago.RequireNone) client.acknowledged = newWriter(kafkago.RequireAll) client.markConnected() return client, nil } func probeKafkaMetadata(ctx context.Context, client *kafkago.Client, topic string) error { if client == nil { return ErrUnavailable } request := &kafkago.MetadataRequest{Topics: []string{}} if topic != "" { request.Topics = []string{topic} } response, err := client.Metadata(nonNilContext(ctx), request) if err != nil { return err } if response == nil || len(response.Brokers) == 0 { return errors.New("kafka metadata contains no brokers") } if topic == "" { return nil } for _, item := range response.Topics { if item.Name != topic { continue } if item.Error != nil { return &kafkaTopicMetadataError{err: item.Error} } if len(item.Partitions) == 0 { return &kafkaTopicMetadataError{err: errors.New("topic has no partitions")} } return nil } return &kafkaTopicMetadataError{err: errors.New("topic metadata was not returned")} } func defaultKafkaConfig(config KafkaConfig) KafkaConfig { brokers := make([]string, 0, len(config.Brokers)) for _, broker := range config.Brokers { if broker = strings.TrimSpace(broker); broker != "" { brokers = append(brokers, broker) } } config.Brokers = brokers config.ClientID = strings.TrimSpace(config.ClientID) config.GroupID = strings.TrimSpace(config.GroupID) config.StartOffset = strings.ToLower(strings.TrimSpace(config.StartOffset)) if config.StartOffset == "" { config.StartOffset = "earliest" } if config.MinBytes <= 0 { config.MinBytes = 1 } if config.MaxBytes <= 0 { config.MaxBytes = 10 << 20 } if config.MaxWait <= 0 { config.MaxWait = time.Second } if config.ConnectTimeout <= 0 { config.ConnectTimeout = 10 * time.Second } if config.ReconnectInterval <= 0 { config.ReconnectInterval = 5 * time.Second } return config } func validateKafkaConfig(config KafkaConfig) error { if len(config.Brokers) == 0 { return errors.New("kafka brokers are empty") } for _, broker := range config.Brokers { host, portText, err := net.SplitHostPort(broker) port, parseErr := strconv.Atoi(portText) if err != nil || parseErr != nil || strings.TrimSpace(host) == "" || port < 1 || port > 65535 { return fmt.Errorf("invalid kafka broker %q", broker) } } if config.ClientID == "" { return errors.New("kafka client id is empty") } if config.GroupID == "" { return errors.New("kafka group id is empty") } if (config.Username == "") != (config.Password == "") { return errors.New("kafka username and password must be configured together") } if config.TLSSkipVerify && !config.TLS { return errors.New("kafka tls skip verify requires tls") } if config.StartOffset != "earliest" && config.StartOffset != "latest" { return fmt.Errorf("invalid kafka start offset %q", config.StartOffset) } if config.MinBytes < 1 || config.MaxBytes < config.MinBytes { return errors.New("kafka byte limits are invalid") } return nil } func kafkaSecurity(config KafkaConfig) (*tls.Config, sasl.Mechanism) { var tlsConfig *tls.Config if config.TLS { tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: config.TLSSkipVerify} //nolint:gosec // explicit operator setting } var mechanism sasl.Mechanism if config.Username != "" { mechanism = plain.Mechanism{Username: config.Username, Password: config.Password} } return tlsConfig, mechanism } func (c *Kafka) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error { topic = strings.TrimSpace(topic) if topic == "" { return errors.New("kafka topic is empty") } if retain { return errors.New("kafka does not support retained messages") } if qos > AtLeastOnce { return fmt.Errorf("kafka supports qos 0 or 1, got %d", qos) } if c == nil { return ErrUnavailable } ctx = nonNilContext(ctx) c.mu.RLock() if c.closed { c.mu.RUnlock() return ErrUnavailable } writer := c.bestEffort if qos == AtLeastOnce { writer = c.acknowledged } config := c.config c.mu.RUnlock() if writer == nil { return ErrUnavailable } err := writeKafkaMessage(ctx, writer, kafkago.Message{Topic: topic, Value: append([]byte(nil), payload...)}, config) if err == nil { c.markConnected() } return err } func writeKafkaMessage(ctx context.Context, writer *kafkago.Writer, message kafkago.Message, config KafkaConfig) error { if writer == nil { return ErrUnavailable } ctx = nonNilContext(ctx) if !config.AllowAutoTopicCreation { return writer.WriteMessages(ctx, message) } attemptCtx, cancel := context.WithTimeout(ctx, config.ConnectTimeout) defer cancel() delay := 100 * time.Millisecond var lastErr error for { lastErr = writer.WriteMessages(attemptCtx, message) if lastErr == nil || !kafkaTopicNotReady(lastErr) { return lastErr } timer := time.NewTimer(delay) select { case <-attemptCtx.Done(): timer.Stop() if err := ctx.Err(); err != nil { return err } return lastErr case <-timer.C: } if delay < time.Second { delay *= 2 if delay > time.Second { delay = time.Second } } } } func kafkaTopicNotReady(err error) bool { if err == nil { return false } if writeErrors, ok := err.(kafkago.WriteErrors); ok { found := false for _, item := range writeErrors { if item == nil { continue } found = true if !kafkaTopicNotReady(item) { return false } } return found } var kafkaErr kafkago.Error if !errors.As(err, &kafkaErr) { return false } return kafkaErr == kafkago.UnknownTopicOrPartition || kafkaErr == kafkago.LeaderNotAvailable || kafkaErr == kafkago.NotLeaderForPartition } func (c *Kafka) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error { topic = strings.TrimSpace(topic) if topic == "" { return errors.New("kafka topic is empty") } if qos > AtLeastOnce { return fmt.Errorf("kafka supports qos 0 or 1, got %d", qos) } if handler == nil { return errors.New("kafka handler is nil") } if c == nil { return ErrUnavailable } ctx = nonNilContext(ctx) select { case <-ctx.Done(): return ctx.Err() default: } c.opMu.Lock() defer c.opMu.Unlock() c.mu.RLock() if c.closed || c.dialer == nil || c.metadata == nil { c.mu.RUnlock() return ErrUnavailable } config := c.config dialer := c.dialer metadata := c.metadata c.mu.RUnlock() if err := probeKafkaMetadata(ctx, metadata, topic); err != nil { var topicErr *kafkaTopicMetadataError if errors.As(err, &topicErr) { c.markConnected() } else if ctx.Err() == nil { c.markUnavailable() } return fmt.Errorf("lookup kafka topic %q: %w", topic, err) } if old := c.removeSubscription(topic); old != nil { closeKafkaSubscription(old) } reader := kafkago.NewReader(kafkago.ReaderConfig{ Brokers: append([]string(nil), config.Brokers...), GroupID: config.GroupID, Topic: topic, Dialer: dialer, MinBytes: config.MinBytes, MaxBytes: config.MaxBytes, MaxWait: config.MaxWait, JoinGroupBackoff: config.ReconnectInterval, ReadLagInterval: -1, StartOffset: kafkaStartOffset(config.StartOffset), }) consumeCtx, cancel := context.WithCancel(context.Background()) subscription := &kafkaSubscription{reader: reader, cancel: cancel, qos: qos, handler: handler} c.mu.Lock() if c.closed { c.mu.Unlock() cancel() _ = reader.Close() return ErrUnavailable } c.subscriptions[topic] = subscription c.mu.Unlock() c.markConnected() go c.consume(consumeCtx, subscription) return nil } func kafkaStartOffset(value string) int64 { if value == "latest" { return kafkago.LastOffset } return kafkago.FirstOffset } func (c *Kafka) consume(ctx context.Context, subscription *kafkaSubscription) { for { var message kafkago.Message var err error if subscription.qos == AtMostOnce { message, err = subscription.reader.ReadMessage(ctx) } else { message, err = subscription.reader.FetchMessage(ctx) } if err != nil { if ctx.Err() != nil || errors.Is(err, io.EOF) { return } c.markUnavailable() if !sleepContext(ctx, c.config.ReconnectInterval) { return } continue } c.markConnected() subscription.handler(ctx, Message{Topic: message.Topic, Payload: append([]byte(nil), message.Value...), QoS: subscription.qos}) if ctx.Err() != nil { return } if subscription.qos == AtLeastOnce { if err = subscription.reader.CommitMessages(ctx, message); err != nil { c.markUnavailable() } } } } func sleepContext(ctx context.Context, duration time.Duration) bool { timer := time.NewTimer(duration) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } } func (c *Kafka) Unsubscribe(ctx context.Context, topics ...string) error { if len(topics) == 0 { return errors.New("kafka topics are empty") } if c == nil { return ErrUnavailable } ctx = nonNilContext(ctx) normalized := make([]string, len(topics)) for index, topic := range topics { normalized[index] = strings.TrimSpace(topic) if normalized[index] == "" { return errors.New("kafka topic is empty") } } c.opMu.Lock() defer c.opMu.Unlock() for _, topic := range normalized { select { case <-ctx.Done(): return ctx.Err() default: } if subscription := c.removeSubscription(topic); subscription != nil { closeKafkaSubscription(subscription) } } return nil } func (c *Kafka) removeSubscription(topic string) *kafkaSubscription { c.mu.Lock() defer c.mu.Unlock() subscription := c.subscriptions[topic] delete(c.subscriptions, topic) return subscription } func closeKafkaSubscription(subscription *kafkaSubscription) { if subscription == nil { return } if subscription.cancel != nil { subscription.cancel() } if subscription.reader != nil { _ = subscription.reader.Close() } } func (c *Kafka) Connected() bool { if c == nil { return false } c.mu.RLock() closed := c.closed c.mu.RUnlock() return !closed && c.connected.Load() } // Reconnecting lets the outer reloadable honor the configured retry delay // before replacing a client after a runtime broker failure. func (c *Kafka) Reconnecting() bool { if c == nil { return false } c.mu.RLock() closed := c.closed c.mu.RUnlock() return !closed && time.Now().UnixNano() < c.reconnectAt.Load() } func (c *Kafka) markConnected() { if c == nil { return } c.mu.RLock() closed := c.closed c.mu.RUnlock() if closed { return } c.reconnectAt.Store(0) c.connected.Store(true) } func (c *Kafka) markUnavailable() { if c == nil { return } c.mu.RLock() closed := c.closed c.mu.RUnlock() if closed { return } c.connected.Store(false) c.reconnectAt.Store(time.Now().Add(c.config.ReconnectInterval).UnixNano()) } func (c *Kafka) Close() error { if c == nil { return nil } c.opMu.Lock() defer c.opMu.Unlock() c.mu.Lock() if c.closed { c.mu.Unlock() return nil } c.closed = true c.connected.Store(false) c.reconnectAt.Store(0) subscriptions := make([]*kafkaSubscription, 0, len(c.subscriptions)) for topic, subscription := range c.subscriptions { subscriptions = append(subscriptions, subscription) delete(c.subscriptions, topic) } bestEffort := c.bestEffort acknowledged := c.acknowledged transport := c.transport c.bestEffort = nil c.acknowledged = nil c.transport = nil c.dialer = nil c.metadata = nil c.mu.Unlock() for _, subscription := range subscriptions { closeKafkaSubscription(subscription) } var result error if bestEffort != nil { result = errors.Join(result, bestEffort.Close()) } if acknowledged != nil { result = errors.Join(result, acknowledged.Close()) } if transport != nil { transport.CloseIdleConnections() } return result }