593 lines
15 KiB
Go
593 lines
15 KiB
Go
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
|
|
}
|