935 lines
29 KiB
Go
935 lines
29 KiB
Go
package consuming
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/centrifugal/centrifugo/v6/internal/api"
|
||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||
"github.com/centrifugal/centrifugo/v6/internal/logging"
|
||
"github.com/centrifugal/centrifugo/v6/internal/metrics"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/aws"
|
||
awsv2cfg "github.com/aws/aws-sdk-go-v2/config"
|
||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||
"github.com/rs/zerolog"
|
||
"github.com/twmb/franz-go/pkg/kerr"
|
||
"github.com/twmb/franz-go/pkg/kgo"
|
||
"github.com/twmb/franz-go/pkg/kmsg"
|
||
mskaws "github.com/twmb/franz-go/pkg/sasl/aws"
|
||
"github.com/twmb/franz-go/pkg/sasl/plain"
|
||
"github.com/twmb/franz-go/pkg/sasl/scram"
|
||
)
|
||
|
||
type KafkaConfig = configtypes.KafkaConsumerConfig
|
||
|
||
type testOnlyConfig struct {
|
||
topicPartitionBeforePauseCh chan topicPartition
|
||
topicPartitionPauseProceedCh chan struct{}
|
||
fetchTopicPartitionSubmittedCh chan kgo.FetchTopicPartition
|
||
// leaveGroupOnShutdown when true sends a manual LeaveGroup request on shutdown.
|
||
// Used only in tests to simulate the old behavior with dynamic instance IDs.
|
||
leaveGroupOnShutdown bool
|
||
// injectFatalPollErrorCh, when a value is received from this channel (non-blocking
|
||
// check on each pollUntilFatal entry), causes pollUntilFatal to return it as a fatal
|
||
// error instead of actually polling — used to exercise the client re-init path in tests.
|
||
injectFatalPollErrorCh chan error
|
||
}
|
||
|
||
type topicPartition struct {
|
||
topic string
|
||
partition int32
|
||
}
|
||
|
||
type KafkaConsumer struct {
|
||
name string
|
||
client *kgo.Client
|
||
dispatcher Dispatcher
|
||
config KafkaConfig
|
||
// consumersMu protects the consumers map. During normal operation, concurrent access is already
|
||
// prevented: franz-go serializes assigned/revoked/lost callbacks, and BlockRebalanceOnPoll
|
||
// prevents callbacks from firing while pollUntilFatal reads the map. The mutex is needed
|
||
// for shutdown, when the main goroutine accesses the map (via allTopicPartitions/killConsumers)
|
||
// while the group management goroutine may still be running callbacks. Must not be held
|
||
// during killConsumers' wg.Wait() to avoid blocking concurrent callbacks.
|
||
consumersMu sync.Mutex
|
||
consumers map[topicPartition]*partitionConsumer
|
||
doneCh chan struct{}
|
||
common *consumerCommon
|
||
testOnlyConfig testOnlyConfig
|
||
}
|
||
|
||
func NewKafkaConsumer(
|
||
config KafkaConfig, dispatcher Dispatcher, common *consumerCommon,
|
||
) (*KafkaConsumer, error) {
|
||
if len(config.Brokers) == 0 {
|
||
return nil, errors.New("brokers required")
|
||
}
|
||
if len(config.Topics) == 0 {
|
||
return nil, errors.New("topics required")
|
||
}
|
||
if len(config.ConsumerGroup) == 0 {
|
||
return nil, errors.New("consumer_group required")
|
||
}
|
||
if config.MaxPollRecords == 0 {
|
||
config.MaxPollRecords = 100
|
||
}
|
||
if config.FetchMaxWait == 0 {
|
||
config.FetchMaxWait = configtypes.Duration(500 * time.Millisecond)
|
||
}
|
||
if config.PartitionQueueMaxSize == -1 {
|
||
config.PartitionQueueMaxSize = 0
|
||
} else if config.PartitionQueueMaxSize == 0 {
|
||
config.PartitionQueueMaxSize = 1000
|
||
}
|
||
consumer := &KafkaConsumer{
|
||
name: common.name,
|
||
dispatcher: dispatcher,
|
||
config: config,
|
||
consumers: make(map[topicPartition]*partitionConsumer),
|
||
doneCh: make(chan struct{}),
|
||
common: common,
|
||
}
|
||
cl, err := consumer.initClient()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error init Kafka client: %w", err)
|
||
}
|
||
consumer.client = cl
|
||
return consumer, nil
|
||
}
|
||
|
||
const kafkaClientID = "centrifugo"
|
||
|
||
// kgoZerologAdapter adapts zerolog.Logger to kgo.Logger interface.
|
||
type kgoZerologAdapter struct {
|
||
log zerolog.Logger
|
||
}
|
||
|
||
func (l *kgoZerologAdapter) Level() kgo.LogLevel {
|
||
switch {
|
||
case logging.Enabled(logging.TraceLevel):
|
||
return kgo.LogLevelDebug // Kafka client debug level is more like our trace.
|
||
case logging.Enabled(logging.InfoLevel):
|
||
return kgo.LogLevelInfo
|
||
case logging.Enabled(logging.WarnLevel):
|
||
return kgo.LogLevelWarn
|
||
case logging.Enabled(logging.ErrorLevel):
|
||
return kgo.LogLevelError
|
||
default:
|
||
return kgo.LogLevelNone
|
||
}
|
||
}
|
||
|
||
func (l *kgoZerologAdapter) Log(level kgo.LogLevel, msg string, keyvals ...any) {
|
||
var e *zerolog.Event
|
||
switch level {
|
||
case kgo.LogLevelError:
|
||
e = l.log.Error()
|
||
case kgo.LogLevelWarn:
|
||
e = l.log.Warn()
|
||
case kgo.LogLevelInfo:
|
||
e = l.log.Info()
|
||
case kgo.LogLevelDebug:
|
||
e = l.log.Trace() // Kafka client debug level is more like our trace.
|
||
default:
|
||
return
|
||
}
|
||
for i := 0; i+1 < len(keyvals); i += 2 {
|
||
if key, ok := keyvals[i].(string); ok {
|
||
e = e.Interface(key, keyvals[i+1])
|
||
}
|
||
}
|
||
e.Msg(msg)
|
||
}
|
||
|
||
// leaveGroup sends a manual LeaveGroup request for static members. franz-go's Close does not
|
||
// send LeaveGroup for static members by design. This method is only used in tests to simulate
|
||
// the old behavior where dynamic instance IDs were combined with a manual LeaveGroup.
|
||
func (c *KafkaConsumer) leaveGroup(ctx context.Context, client *kgo.Client) error {
|
||
req := kmsg.NewPtrLeaveGroupRequest()
|
||
req.Group = c.config.ConsumerGroup
|
||
instanceID := c.config.InstanceID
|
||
reason := "shutdown"
|
||
req.Members = []kmsg.LeaveGroupRequestMember{
|
||
{
|
||
InstanceID: &instanceID,
|
||
Reason: &reason,
|
||
},
|
||
}
|
||
_, err := req.RequestWith(ctx, client)
|
||
return err
|
||
}
|
||
|
||
// mskAssumeRoleSessionName is the STS RoleSessionName for AssumeRole calls.
|
||
const mskAssumeRoleSessionName = "centrifugo-msk"
|
||
|
||
// mskAssumeRoleAuth obtains fresh STS credentials for each MSK IAM SASL handshake.
|
||
// Call AssumeRole directly on every callback; do not use CredentialsCache, which can
|
||
// return the same credentials MSK already considers expired (see franz-go #731).
|
||
// RoleSessionName stays constant so the broker principal is stable across re-auth
|
||
// (see aws-msk-iam-auth README).
|
||
type mskAssumeRoleAuth struct {
|
||
stsClient *sts.Client
|
||
roleARN string
|
||
}
|
||
|
||
func newMSKAssumeRoleAuth(ctx context.Context, roleARN string) (*mskAssumeRoleAuth, error) {
|
||
awsCfg, err := awsv2cfg.LoadDefaultConfig(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &mskAssumeRoleAuth{
|
||
stsClient: sts.NewFromConfig(awsCfg),
|
||
roleARN: roleARN,
|
||
}, nil
|
||
}
|
||
|
||
func (a *mskAssumeRoleAuth) auth(ctx context.Context) (mskaws.Auth, error) {
|
||
out, err := a.stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
|
||
RoleArn: aws.String(a.roleARN),
|
||
RoleSessionName: aws.String(mskAssumeRoleSessionName),
|
||
})
|
||
if err != nil {
|
||
return mskaws.Auth{}, err
|
||
}
|
||
if out.Credentials == nil {
|
||
return mskaws.Auth{}, errors.New("assume role returned no credentials")
|
||
}
|
||
creds := out.Credentials
|
||
if creds.AccessKeyId == nil || creds.SecretAccessKey == nil {
|
||
return mskaws.Auth{}, errors.New("assume role returned incomplete credentials")
|
||
}
|
||
sessionToken := ""
|
||
if creds.SessionToken != nil {
|
||
sessionToken = *creds.SessionToken
|
||
}
|
||
return mskaws.Auth{
|
||
AccessKey: *creds.AccessKeyId,
|
||
SecretKey: *creds.SecretAccessKey,
|
||
SessionToken: sessionToken,
|
||
}, nil
|
||
}
|
||
|
||
// defaultKafkaDialTimeout is the fallback TCP dial timeout to a single broker.
|
||
const defaultKafkaDialTimeout = 3 * time.Second
|
||
|
||
func effectiveDialTimeout(d configtypes.Duration) time.Duration {
|
||
if d <= 0 {
|
||
return defaultKafkaDialTimeout
|
||
}
|
||
return time.Duration(d)
|
||
}
|
||
|
||
// pingTimeout returns a context deadline long enough for Ping to try every
|
||
// seed broker. franz-go retries each broker's connection once on retryable
|
||
// errors (see broker.handleReq), so each broker may cost up to 2 × dialTimeout.
|
||
func pingTimeout(dialTimeout time.Duration, numBrokers int) time.Duration {
|
||
const maxAttemptsPerBroker = 2
|
||
return dialTimeout * time.Duration(numBrokers) * maxAttemptsPerBroker
|
||
}
|
||
|
||
func (c *KafkaConsumer) initClient() (*kgo.Client, error) {
|
||
dialTimeout := effectiveDialTimeout(c.config.DialTimeout)
|
||
opts := []kgo.Opt{
|
||
kgo.SeedBrokers(c.config.Brokers...),
|
||
kgo.DialTimeout(dialTimeout),
|
||
kgo.ConsumeTopics(c.config.Topics...),
|
||
kgo.ConsumerGroup(c.config.ConsumerGroup),
|
||
kgo.OnPartitionsAssigned(c.assigned),
|
||
kgo.OnPartitionsRevoked(c.revoked),
|
||
kgo.OnPartitionsLost(c.lost),
|
||
kgo.AutoCommitMarks(),
|
||
kgo.BlockRebalanceOnPoll(),
|
||
kgo.ClientID(kafkaClientID),
|
||
kgo.FetchMaxWait(time.Duration(c.config.FetchMaxWait)),
|
||
kgo.WithLogger(&kgoZerologAdapter{log: c.common.log}),
|
||
}
|
||
if c.config.InstanceID != "" { // Important to keep separate, otherwise pointer to empty string is used in kgo.InstanceID.
|
||
opts = append(opts, kgo.InstanceID(c.config.InstanceID))
|
||
}
|
||
if c.config.FetchMaxBytes > 0 {
|
||
opts = append(opts, kgo.FetchMaxBytes(c.config.FetchMaxBytes))
|
||
}
|
||
if !c.config.FetchReadUncommitted {
|
||
opts = append(opts, kgo.FetchIsolationLevel(kgo.ReadCommitted()))
|
||
} else {
|
||
opts = append(opts, kgo.FetchIsolationLevel(kgo.ReadUncommitted()))
|
||
}
|
||
if c.config.TLS.Enabled {
|
||
tlsConfig, err := c.config.TLS.ToGoTLSConfig("kafka:" + c.name)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error making TLS configuration: %w", err)
|
||
}
|
||
dialer := &tls.Dialer{
|
||
NetDialer: &net.Dialer{Timeout: 10 * time.Second},
|
||
Config: tlsConfig,
|
||
}
|
||
opts = append(opts, kgo.Dialer(dialer.DialContext))
|
||
}
|
||
|
||
if c.config.SASLMechanism != "" {
|
||
switch c.config.SASLMechanism {
|
||
case "plain":
|
||
opts = append(opts, kgo.SASL(plain.Auth{
|
||
User: c.config.SASLUser,
|
||
Pass: c.config.SASLPassword,
|
||
}.AsMechanism()))
|
||
case "scram-sha-256":
|
||
opts = append(opts, kgo.SASL(scram.Auth{
|
||
User: c.config.SASLUser,
|
||
Pass: c.config.SASLPassword,
|
||
}.AsSha256Mechanism()))
|
||
case "scram-sha-512":
|
||
opts = append(opts, kgo.SASL(scram.Auth{
|
||
User: c.config.SASLUser,
|
||
Pass: c.config.SASLPassword,
|
||
}.AsSha512Mechanism()))
|
||
case "aws-msk-iam":
|
||
if c.config.AssumeRoleARN != "" {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
mskAuth, err := newMSKAssumeRoleAuth(ctx, c.config.AssumeRoleARN)
|
||
cancel()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load AWS config for MSK IAM assume role: %w", err)
|
||
}
|
||
opts = append(opts, kgo.SASL(mskaws.ManagedStreamingIAM(func(ctx context.Context) (mskaws.Auth, error) {
|
||
return mskAuth.auth(ctx)
|
||
})))
|
||
} else {
|
||
opts = append(opts, kgo.SASL(mskaws.Auth{
|
||
AccessKey: c.config.SASLUser,
|
||
SecretKey: c.config.SASLPassword,
|
||
}.AsManagedStreamingIAMMechanism()))
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("unsupported SASL mechanism: %s", c.config.SASLMechanism)
|
||
}
|
||
}
|
||
|
||
client, err := kgo.NewClient(opts...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error initializing client: %w", err)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), pingTimeout(dialTimeout, len(c.config.Brokers)))
|
||
defer cancel()
|
||
if err := client.Ping(ctx); err != nil {
|
||
client.Close()
|
||
return nil, fmt.Errorf("error ping Kafka: %w", err)
|
||
}
|
||
return client, nil
|
||
}
|
||
|
||
// fetchPartitionErrorRequiresClientReinit reports whether a PollRecords fetch error
|
||
// should trigger a full consumer client re-initialization. franz-go classifies Kafka
|
||
// protocol errors with kerr.IsRetriable; broker-level retryability uses
|
||
// kgo.IsRetryableBrokerErr — these differ, so both are consulted. *kgo.ErrDataLoss
|
||
// is handled by offset reset inside the client and does not require restarting it.
|
||
func fetchPartitionErrorRequiresClientReinit(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
var dataLoss *kgo.ErrDataLoss
|
||
if errors.As(err, &dataLoss) {
|
||
return false
|
||
}
|
||
if kerr.IsRetriable(err) || kgo.IsRetryableBrokerErr(err) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (c *KafkaConsumer) Run(ctx context.Context) error {
|
||
started := time.Now()
|
||
defer func() {
|
||
c.common.log.Info().Str("was_running_for", time.Since(started).String()).Msg("stopping Kafka consumer")
|
||
stopStarted := time.Now()
|
||
close(c.doneCh)
|
||
if c.client != nil {
|
||
// Stop all partition consumers first to ensure all offsets are marked.
|
||
c.killConsumers(c.allTopicPartitions())
|
||
if c.testOnlyConfig.leaveGroupOnShutdown && c.config.InstanceID != "" {
|
||
// Test-only path simulating old behavior with dynamic instance IDs.
|
||
// Commit before manual LeaveGroup — the coordinator rejects commits
|
||
// from members that already left the group.
|
||
closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
if err := c.client.CommitMarkedOffsets(closeCtx); err != nil {
|
||
c.common.log.Error().Err(err).Msg("error committing marked offsets on shutdown")
|
||
}
|
||
if err := c.leaveGroup(closeCtx, c.client); err != nil {
|
||
c.common.log.Error().Err(err).Msg("error leaving consumer group")
|
||
}
|
||
}
|
||
// CloseAllowingRebalance calls AllowRebalance (required since we use
|
||
// BlockRebalanceOnPoll) and then Close. Close triggers the manage loop
|
||
// exit which calls onRevoked with a valid client context and no
|
||
// joinAndSync WLock contention — so CommitMarkedOffsets in onRevoked
|
||
// succeeds without racing. For non-static members (no InstanceID),
|
||
// Close sends LeaveGroup automatically. For static members (InstanceID
|
||
// is set), Close does not send LeaveGroup – so the replacement consumer
|
||
// with the same instance ID can take over partitions seamlessly.
|
||
c.client.CloseAllowingRebalance()
|
||
}
|
||
c.common.log.Info().Str("elapsed", time.Since(stopStarted).String()).Msg("stopped Kafka consumer")
|
||
}()
|
||
for {
|
||
pollStarted := time.Now()
|
||
err := c.pollUntilFatal(ctx)
|
||
if err != nil {
|
||
// franz-go may return context.DeadlineExceeded instead of context.Canceled on
|
||
// shutdown, so check the external context to detect shutdown — avoids logging
|
||
// the unwrapped error as if it were a real polling failure.
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
c.common.log.Error().Err(err).Msg("fatal error polling Kafka, need client re-init")
|
||
}
|
||
// Upon returning from polling loop with fatal error we re-initialize consumer client.
|
||
c.client.CloseAllowingRebalance()
|
||
c.client = nil
|
||
c.common.log.Info().Str("was_polling_for", time.Since(pollStarted).String()).Msg("start re-initializing Kafka consumer client")
|
||
reInitStarted := time.Now()
|
||
err = c.reInitClient(ctx)
|
||
if err != nil {
|
||
// Only context.Canceled may be returned.
|
||
return err
|
||
}
|
||
c.common.log.Info().Str("elapsed", time.Since(reInitStarted).String()).Msg("Kafka consumer client re-initialized")
|
||
}
|
||
}
|
||
|
||
func (c *KafkaConsumer) pollUntilFatal(ctx context.Context) error {
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
if ch := c.testOnlyConfig.injectFatalPollErrorCh; ch != nil {
|
||
select {
|
||
case err := <-ch:
|
||
return err
|
||
default:
|
||
}
|
||
}
|
||
fetches := c.client.PollRecords(ctx, c.config.MaxPollRecords)
|
||
if fetches.IsClientClosed() {
|
||
// Defensive check: in our code the client is only closed by Run after this loop
|
||
// exits, so this should not normally happen. Trigger re-init via Run to recover.
|
||
c.common.log.Warn().Msg("Kafka client unexpectedly closed during poll, will re-init")
|
||
return errors.New("client closed during poll")
|
||
}
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
// Check fetch errors. Only return fatal error if at least one partition error needs a
|
||
// full client re-init. Kafka-retriable protocol errors (kerr) and retryable broker
|
||
// errors (kgo) are left to franz-go; ErrDataLoss is handled via offset reset inside the
|
||
// client. Otherwise we log and continue so EachPartition still processes fetched
|
||
// records for partitions without errors.
|
||
fetchErrors := fetches.Errors()
|
||
if len(fetchErrors) > 0 {
|
||
var errs []error
|
||
var fatal bool
|
||
for _, fetchErr := range fetchErrors {
|
||
errs = append(errs, fetchErr.Err)
|
||
reinit := fetchPartitionErrorRequiresClientReinit(fetchErr.Err)
|
||
c.common.log.Error().
|
||
Err(fetchErr.Err).
|
||
Str("topic", fetchErr.Topic).
|
||
Int32("partition", fetchErr.Partition).
|
||
Bool("fatal", reinit).
|
||
Msg("error while polling topic partition")
|
||
if reinit {
|
||
fatal = true
|
||
}
|
||
}
|
||
if fatal {
|
||
// Client will be re-initialized in Run. This poll returns before EachPartition,
|
||
// so records from this cycle are not enqueued yet; recovery is at-least-once
|
||
// via refetch after re-init.
|
||
return fmt.Errorf("poll error: %w", errors.Join(errs...))
|
||
}
|
||
}
|
||
|
||
// Track which partitions we've paused in this poll cycle.
|
||
pausedTopicPartitions := map[topicPartition]struct{}{}
|
||
|
||
fetches.EachPartition(func(p kgo.FetchTopicPartition) {
|
||
if len(p.Records) == 0 {
|
||
return
|
||
}
|
||
|
||
tp := topicPartition{p.Topic, p.Partition}
|
||
|
||
consumer := c.consumers[tp]
|
||
if consumer == nil {
|
||
return
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-consumer.partitionCtx.Done():
|
||
return
|
||
default:
|
||
}
|
||
|
||
partitionsToPause := map[string][]int32{p.Topic: {p.Partition}}
|
||
if _, paused := pausedTopicPartitions[tp]; !paused {
|
||
// Only pause if queue threshold would be exceeded after adding records, or if threshold is 0 (always pause).
|
||
shouldPause := consumer.queue.NumRecords()+len(p.Records) >= c.config.PartitionQueueMaxSize
|
||
if shouldPause {
|
||
// Pause partition BEFORE submitting to queue to avoid race condition
|
||
// between pause and resume operations.
|
||
if c.testOnlyConfig.topicPartitionBeforePauseCh != nil {
|
||
c.testOnlyConfig.topicPartitionBeforePauseCh <- tp
|
||
}
|
||
if c.testOnlyConfig.topicPartitionPauseProceedCh != nil {
|
||
<-c.testOnlyConfig.topicPartitionPauseProceedCh
|
||
}
|
||
c.client.PauseFetchPartitions(partitionsToPause)
|
||
pausedTopicPartitions[tp] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// Clone p.Records to a fresh, tightly-sized backing array before enqueueing.
|
||
// The []*kgo.Record slice returned by franz-go points into the per-partition
|
||
// array franz-go grew via append while decoding the fetch response. That array
|
||
// can hold many more *Record pointers than the window we take here (up to
|
||
// MaxPollRecords); Go's GC tracks the whole backing allocation, so retaining
|
||
// the sub-slice keeps every pointer in the original array reachable — including
|
||
// records semantically stripped when the partition gets paused. That in turn
|
||
// keeps their Record structs and the batch buffers their Values point into
|
||
// alive, amplifying heap usage under lag. Cloning breaks the reference so the
|
||
// queue only pins the records we actually intend to process.
|
||
p.Records = slices.Clone(p.Records)
|
||
|
||
// Now submit to unbounded queue.
|
||
if !consumer.queue.Push(p) {
|
||
// Queue is closed, partition consumer is shutting down
|
||
// Resume the partition if we paused it but won't process
|
||
if _, wasPaused := pausedTopicPartitions[tp]; wasPaused {
|
||
c.client.ResumeFetchPartitions(partitionsToPause)
|
||
}
|
||
return
|
||
}
|
||
|
||
if c.testOnlyConfig.fetchTopicPartitionSubmittedCh != nil {
|
||
c.testOnlyConfig.fetchTopicPartitionSubmittedCh <- p
|
||
}
|
||
})
|
||
|
||
c.client.AllowRebalance()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *KafkaConsumer) reInitClient(ctx context.Context) error {
|
||
var backoffDuration time.Duration = 0
|
||
retries := 0
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
}
|
||
client, err := c.initClient()
|
||
if err != nil {
|
||
retries++
|
||
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
|
||
c.common.log.Error().Err(err).Msg("error initializing Kafka client")
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(backoffDuration):
|
||
continue
|
||
}
|
||
}
|
||
c.client = client
|
||
break
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *KafkaConsumer) assigned(ctx context.Context, cl *kgo.Client, assigned map[string][]int32) {
|
||
c.consumersMu.Lock()
|
||
defer c.consumersMu.Unlock()
|
||
select {
|
||
case <-c.doneCh:
|
||
return // Shutdown in progress, skip creating consumers that will be immediately killed.
|
||
default:
|
||
}
|
||
for topic, partitions := range assigned {
|
||
for _, partition := range partitions {
|
||
partitionCtx, cancel := context.WithCancel(ctx)
|
||
pc := &partitionConsumer{
|
||
partitionCtx: partitionCtx,
|
||
cancelFunc: cancel,
|
||
dispatcher: c.dispatcher,
|
||
cl: cl,
|
||
topic: topic,
|
||
partition: partition,
|
||
config: c.config,
|
||
name: c.name,
|
||
common: c.common,
|
||
|
||
done: make(chan struct{}),
|
||
queue: newUnboundedQueue(),
|
||
}
|
||
c.consumers[topicPartition{topic, partition}] = pc
|
||
go pc.consume()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *KafkaConsumer) revoked(ctx context.Context, cl *kgo.Client, revoked map[string][]int32) {
|
||
c.killConsumers(revoked)
|
||
// Always commit marked offsets on revoke.
|
||
if err := cl.CommitMarkedOffsets(ctx); err != nil {
|
||
// On shutdown the app context is already canceled; commit will fail with
|
||
// context.Canceled. That is expected, not an error — log at info level instead.
|
||
if errors.Is(err, context.Canceled) {
|
||
c.common.log.Info().Err(err).Msg("skipping commit on revoke due to context cancellation")
|
||
} else {
|
||
c.common.log.Error().Err(err).Msg("error committing marked offsets on revoke")
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *KafkaConsumer) lost(_ context.Context, _ *kgo.Client, lost map[string][]int32) {
|
||
c.killConsumers(lost)
|
||
// Losing means we cannot commit: an error happened.
|
||
}
|
||
|
||
func (c *KafkaConsumer) allTopicPartitions() map[string][]int32 {
|
||
c.consumersMu.Lock()
|
||
defer c.consumersMu.Unlock()
|
||
result := make(map[string][]int32)
|
||
for tp := range c.consumers {
|
||
result[tp.topic] = append(result[tp.topic], tp.partition)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (c *KafkaConsumer) killConsumers(lost map[string][]int32) {
|
||
c.consumersMu.Lock()
|
||
var pcs []*partitionConsumer
|
||
for topic, partitions := range lost {
|
||
for _, partition := range partitions {
|
||
tp := topicPartition{topic, partition}
|
||
pc := c.consumers[tp]
|
||
if pc == nil {
|
||
continue
|
||
}
|
||
delete(c.consumers, tp)
|
||
pcs = append(pcs, pc)
|
||
}
|
||
}
|
||
c.consumersMu.Unlock()
|
||
|
||
var wg sync.WaitGroup
|
||
for _, pc := range pcs {
|
||
pc.queue.Close()
|
||
pc.cancelFunc()
|
||
wg.Add(1)
|
||
go func() { <-pc.done; wg.Done() }()
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
type partitionConsumer struct {
|
||
partitionCtx context.Context
|
||
cancelFunc context.CancelFunc
|
||
dispatcher Dispatcher
|
||
cl *kgo.Client
|
||
topic string
|
||
partition int32
|
||
config KafkaConfig
|
||
name string
|
||
common *consumerCommon
|
||
|
||
done chan struct{}
|
||
queue *unboundedQueue
|
||
}
|
||
|
||
func getUint64HeaderValue(record *kgo.Record, headerKey string) (uint64, error) {
|
||
if headerKey == "" {
|
||
return 0, nil
|
||
}
|
||
headerValue := getHeaderValue(record, headerKey)
|
||
if headerValue == "" {
|
||
return 0, nil
|
||
}
|
||
value, err := strconv.ParseUint(headerValue, 10, 64)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("error parsing header %s value %s: %w", headerKey, headerValue, err)
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func getBoolHeaderValue(record *kgo.Record, headerKey string) (bool, error) {
|
||
if headerKey == "" {
|
||
return false, nil
|
||
}
|
||
headerValue := getHeaderValue(record, headerKey)
|
||
if headerValue == "" {
|
||
return false, nil
|
||
}
|
||
value, err := strconv.ParseBool(headerValue)
|
||
if err != nil {
|
||
return false, fmt.Errorf("error parsing header %s value %s: %w", headerKey, headerValue, err)
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func getHeaderValue(record *kgo.Record, headerKey string) string {
|
||
if headerKey == "" {
|
||
return ""
|
||
}
|
||
for _, header := range record.Headers {
|
||
if header.Key == headerKey {
|
||
return string(header.Value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func publicationTagsFromKafkaRecord(record *kgo.Record, tagsHeaderPrefix string) map[string]string {
|
||
if tagsHeaderPrefix == "" {
|
||
return nil
|
||
}
|
||
var tags map[string]string
|
||
for _, header := range record.Headers {
|
||
if strings.HasPrefix(header.Key, tagsHeaderPrefix) {
|
||
if tags == nil {
|
||
tags = make(map[string]string)
|
||
}
|
||
tags[header.Key[len(tagsHeaderPrefix):]] = string(header.Value)
|
||
}
|
||
}
|
||
return tags
|
||
}
|
||
|
||
func (pc *partitionConsumer) processPublicationDataRecord(ctx context.Context, record *kgo.Record) error {
|
||
data := record.Value
|
||
idempotencyKey := getHeaderValue(record, pc.config.PublicationDataMode.IdempotencyKeyHeader)
|
||
delta, err := getBoolHeaderValue(record, pc.config.PublicationDataMode.DeltaHeader)
|
||
if err != nil {
|
||
pc.common.log.Error().Err(err).Str("topic", record.Topic).Int32("partition", record.Partition).Msg("error parsing delta header value, skip message")
|
||
return nil
|
||
}
|
||
channelsHeader := getHeaderValue(record, pc.config.PublicationDataMode.ChannelsHeader)
|
||
if channelsHeader == "" {
|
||
pc.common.log.Info().Str("topic", record.Topic).Int32("partition", record.Partition).Msg("no channels found, skip message")
|
||
return nil
|
||
}
|
||
channels := strings.Split(channelsHeader, ",")
|
||
if len(channels) == 0 || (len(channels) == 1 && channels[0] == "") {
|
||
pc.common.log.Info().Str("topic", record.Topic).Int32("partition", record.Partition).Msg("no channels found, skip message")
|
||
return nil
|
||
}
|
||
version, err := getUint64HeaderValue(record, pc.config.PublicationDataMode.VersionHeader)
|
||
if err != nil {
|
||
pc.common.log.Error().Err(err).Str("topic", record.Topic).Int32("partition", record.Partition).Msg("error parsing version header value, skip message")
|
||
return nil
|
||
}
|
||
return pc.dispatcher.DispatchPublication(
|
||
ctx,
|
||
channels,
|
||
api.ConsumedPublication{
|
||
Data: data,
|
||
IdempotencyKey: idempotencyKey,
|
||
Delta: delta,
|
||
Tags: publicationTagsFromKafkaRecord(record, pc.config.PublicationDataMode.TagsHeaderPrefix),
|
||
Version: version,
|
||
VersionEpoch: getHeaderValue(record, pc.config.PublicationDataMode.VersionEpochHeader),
|
||
},
|
||
)
|
||
}
|
||
|
||
func (pc *partitionConsumer) processRecord(ctx context.Context, record *kgo.Record) error {
|
||
if pc.config.PublicationDataMode.Enabled {
|
||
return pc.processPublicationDataRecord(ctx, record)
|
||
}
|
||
method := getHeaderValue(record, pc.config.MethodHeader)
|
||
return pc.dispatcher.DispatchCommand(ctx, method, record.Value)
|
||
}
|
||
|
||
func (pc *partitionConsumer) processRecords(records []*kgo.Record) {
|
||
for _, record := range records {
|
||
select {
|
||
case <-pc.partitionCtx.Done():
|
||
return
|
||
default:
|
||
}
|
||
|
||
var backoffDuration time.Duration = 0
|
||
retries := 0
|
||
for {
|
||
err := pc.processRecord(pc.partitionCtx, record)
|
||
if err == nil {
|
||
if retries > 0 {
|
||
pc.common.log.Info().Str("topic", record.Topic).Int32("partition", record.Partition).Msg("OK processing message after errors")
|
||
}
|
||
if logging.Enabled(logging.TraceLevel) {
|
||
pc.common.log.Trace().Str("topic", record.Topic).Int32("partition", record.Partition).Int64("offset", record.Offset).Msg("record processed")
|
||
}
|
||
metrics.ConsumerProcessedTotal.WithLabelValues(pc.name).Inc()
|
||
pc.cl.MarkCommitRecords(record)
|
||
break
|
||
}
|
||
// Only stop on our own cancellation: an unrelated context.Canceled bubbling up
|
||
// from the dispatcher must be retried, not treated as shutdown. Bailing out here
|
||
// would skip the rest of the batch and let a later batch commit an offset past
|
||
// this record — losing it for good.
|
||
if pc.partitionCtx.Err() != nil {
|
||
return
|
||
}
|
||
retries++
|
||
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
|
||
metrics.ConsumerErrorsTotal.WithLabelValues(pc.name).Inc()
|
||
pc.common.log.Error().Err(err).Str("topic", record.Topic).Int32("partition", record.Partition).Str("next_attempt_in", backoffDuration.String()).Msg("error processing consumed record")
|
||
select {
|
||
case <-time.After(backoffDuration):
|
||
case <-pc.partitionCtx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (pc *partitionConsumer) consume() {
|
||
if logging.Enabled(logging.DebugLevel) {
|
||
pc.common.log.Debug().Str("topic", pc.topic).Int32("partition", pc.partition).Msg("starting partition consumer")
|
||
}
|
||
defer close(pc.done)
|
||
defer pc.queue.Close()
|
||
defer func() {
|
||
if logging.Enabled(logging.DebugLevel) {
|
||
// Queue is closed, partition consumer is shutting down.
|
||
pc.common.log.Debug().Str("topic", pc.topic).Int32("partition", pc.partition).Msg("partition consumer shutting down")
|
||
}
|
||
}()
|
||
|
||
partitionsToResume := map[string][]int32{pc.topic: {pc.partition}}
|
||
resumeConsuming := func() {
|
||
pc.cl.ResumeFetchPartitions(partitionsToResume)
|
||
}
|
||
defer resumeConsuming()
|
||
|
||
for {
|
||
select {
|
||
case <-pc.partitionCtx.Done():
|
||
return
|
||
case <-pc.queue.NotEmpty():
|
||
if pc.queue.IsClosed() {
|
||
return
|
||
}
|
||
// Process all available items in the queue.
|
||
for {
|
||
select {
|
||
case <-pc.partitionCtx.Done():
|
||
return
|
||
default:
|
||
}
|
||
p, ok := pc.queue.Pop()
|
||
if !ok {
|
||
break
|
||
}
|
||
pc.processRecords(p.Records)
|
||
// Resume partition after processing this batch. Only one additional batch will
|
||
// be added to the queue before we pause again.
|
||
resumeConsuming()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// unboundedQueue implements an unbounded queue for FetchTopicPartition
|
||
type unboundedQueue struct {
|
||
mu sync.RWMutex
|
||
items []kgo.FetchTopicPartition
|
||
numRecords int
|
||
notEmpty chan struct{}
|
||
closed bool
|
||
}
|
||
|
||
func newUnboundedQueue() *unboundedQueue {
|
||
return &unboundedQueue{
|
||
items: make([]kgo.FetchTopicPartition, 0),
|
||
notEmpty: make(chan struct{}, 1),
|
||
}
|
||
}
|
||
|
||
func (q *unboundedQueue) Push(item kgo.FetchTopicPartition) bool {
|
||
q.mu.Lock()
|
||
defer q.mu.Unlock()
|
||
|
||
if q.closed {
|
||
return false
|
||
}
|
||
|
||
q.items = append(q.items, item)
|
||
q.numRecords += len(item.Records)
|
||
|
||
// Signal that queue is not empty.
|
||
select {
|
||
case q.notEmpty <- struct{}{}:
|
||
default:
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func (q *unboundedQueue) Pop() (kgo.FetchTopicPartition, bool) {
|
||
q.mu.Lock()
|
||
defer q.mu.Unlock()
|
||
|
||
if q.closed || len(q.items) == 0 {
|
||
return kgo.FetchTopicPartition{}, false
|
||
}
|
||
|
||
// We do not expect many items in the queue, so we can use a simple slice pop operation and
|
||
// not worry about memory being reserved for the underlying slice.
|
||
item := q.items[0]
|
||
q.items[0] = kgo.FetchTopicPartition{} // Clear the first item for faster GC.
|
||
q.items = q.items[1:]
|
||
q.numRecords -= len(item.Records)
|
||
|
||
return item, true
|
||
}
|
||
|
||
func (q *unboundedQueue) NumRecords() int {
|
||
q.mu.RLock()
|
||
defer q.mu.RUnlock()
|
||
return q.numRecords
|
||
}
|
||
|
||
func (q *unboundedQueue) Close() {
|
||
q.mu.Lock()
|
||
defer q.mu.Unlock()
|
||
|
||
if !q.closed {
|
||
q.closed = true
|
||
close(q.notEmpty)
|
||
}
|
||
}
|
||
|
||
func (q *unboundedQueue) IsClosed() bool {
|
||
q.mu.RLock()
|
||
defer q.mu.RUnlock()
|
||
return q.closed
|
||
}
|
||
|
||
func (q *unboundedQueue) NotEmpty() <-chan struct{} {
|
||
return q.notEmpty
|
||
}
|