snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
test / Lint (push) Failing after 3s
test / Test with Go 1.26.8 (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:17:21 +05:00
commit b352a9a30d
472 changed files with 108375 additions and 0 deletions

View File

@@ -0,0 +1,448 @@
package consuming
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/logging"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/aws/aws-sdk-go-v2/service/sqs/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
endpoints "github.com/aws/smithy-go/endpoints"
)
type AwsSqsConsumerConfig = configtypes.AwsSqsConsumerConfig
// AwsSqsConsumer consumes messages from AWS SQS (or SNS delivered to SQS).
// It uses per-group ordering when enabled and supports both command and publication data modes.
type AwsSqsConsumer struct {
config AwsSqsConsumerConfig
dispatcher Dispatcher
client *sqs.Client
common *consumerCommon
}
type overrideEndpointResolver struct {
Endpoint endpoints.Endpoint
}
func (o overrideEndpointResolver) ResolveEndpoint(_ context.Context, _ sqs.EndpointParameters) (endpoints.Endpoint, error) {
return o.Endpoint, nil
}
// NewAwsSqsConsumer creates and initializes a new AwsSqsConsumer.
func NewAwsSqsConsumer(
cfg AwsSqsConsumerConfig, dispatcher Dispatcher, common *consumerCommon,
) (*AwsSqsConsumer, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
loadOpts := []func(*config.LoadOptions) error{
config.WithRegion(cfg.Region),
}
if cfg.CredentialsProfile != "" {
loadOpts = append(loadOpts, config.WithSharedConfigProfile(cfg.CredentialsProfile))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
awsCfg, err := config.LoadDefaultConfig(ctx, loadOpts...)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
var sqsOpts []func(*sqs.Options)
var client *sqs.Client
if cfg.LocalStackEndpoint != "" {
parsedURL, err := url.Parse(cfg.LocalStackEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse LocalStack endpoint: %w", err)
}
sqsOpts = append(sqsOpts,
sqs.WithEndpointResolverV2(overrideEndpointResolver{
Endpoint: endpoints.Endpoint{
URI: *parsedURL,
},
}),
)
}
// If AssumeRoleARN is provided, assume that role.
if cfg.AssumeRoleARN != "" {
stsClient := sts.NewFromConfig(awsCfg)
assumeRoleProvider := stscreds.NewAssumeRoleProvider(stsClient, cfg.AssumeRoleARN)
awsCfg.Credentials = aws.NewCredentialsCache(assumeRoleProvider)
}
client = sqs.NewFromConfig(awsCfg, sqsOpts...)
consumer := &AwsSqsConsumer{
config: cfg,
dispatcher: dispatcher,
client: client,
common: common,
}
return consumer, nil
}
// Run is the main loop that receives messages from each queue and processes them in batches.
func (c *AwsSqsConsumer) Run(ctx context.Context) error {
var wg sync.WaitGroup
maxConcurrency := c.config.MaxConcurrency
if maxConcurrency < 1 {
maxConcurrency = 1
}
// Spawn one goroutine per queue URL.
for _, queueURL := range c.config.Queues {
wg.Add(1)
go func(qURL string) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
}
waitTimeSeconds := int32(c.config.PollWaitTime.ToDuration().Seconds())
if waitTimeSeconds < 1 {
waitTimeSeconds = 1
}
visibilityTimeoutSeconds := int32(c.config.VisibilityTimeout.ToDuration().Seconds())
if visibilityTimeoutSeconds < 1 {
visibilityTimeoutSeconds = 1
}
out, err := c.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(qURL),
MaxNumberOfMessages: c.config.MaxNumberOfMessages,
WaitTimeSeconds: waitTimeSeconds,
VisibilityTimeout: visibilityTimeoutSeconds,
MessageAttributeNames: []string{"All"},
MessageSystemAttributeNames: []types.MessageSystemAttributeName{"All"},
})
if err != nil {
c.common.log.Error().Err(err).Msgf("failed to receive messages from queue %s", qURL)
select {
case <-ctx.Done():
return
case <-time.After(1 * time.Second):
}
continue
}
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("queue", qURL).Int("num_messages", len(out.Messages)).
Msg("received messages from queue")
}
if len(out.Messages) == 0 {
continue
}
c.processMessages(ctx, out.Messages, qURL, maxConcurrency)
}
}(queueURL)
}
// Wait for context cancellation.
<-ctx.Done()
wg.Wait()
return ctx.Err()
}
// processMessages partitions messages into unordered and ordered groups,
// then processes them appropriately. It passes the queue URL to batch deletion.
func (c *AwsSqsConsumer) processMessages(
ctx context.Context, messages []types.Message, queueURL string, maxConcurrency int,
) {
// Partition messages into unordered and ordered groups.
var unorderedMessages []types.Message
orderedGroups := make(map[string][]types.Message, len(messages))
for _, msg := range messages {
groupID := ""
if msg.Attributes != nil {
if id, ok := msg.Attributes[string(types.MessageSystemAttributeNameMessageGroupId)]; ok && id != "" {
groupID = id
}
}
if groupID == "" {
unorderedMessages = append(unorderedMessages, msg)
} else {
orderedGroups[groupID] = append(orderedGroups[groupID], msg)
}
}
// Channels or maps to collect successfully processed messages.
var (
unorderedProcessed []types.Message
orderedProcessed = make(map[string][]types.Message)
)
var wg sync.WaitGroup
var mu sync.Mutex
sem := make(chan struct{}, maxConcurrency)
// Process unordered messages concurrently.
for _, msg := range unorderedMessages {
wg.Add(1)
sem <- struct{}{}
go func(m types.Message) {
defer wg.Done()
defer func() { <-sem }()
if c.processSingleMessage(ctx, m) {
mu.Lock()
unorderedProcessed = append(unorderedProcessed, m)
mu.Unlock()
}
}(msg)
}
// Process each ordered group sequentially in its own goroutine.
for groupID, groupMessages := range orderedGroups {
wg.Add(1)
sem <- struct{}{}
go func(groupID string, groupMessages []types.Message) {
defer wg.Done()
defer func() { <-sem }()
processedGroup := make([]types.Message, 0, len(groupMessages))
// Process messages in the group sequentially.
for _, m := range groupMessages {
if c.processSingleMessage(ctx, m) {
processedGroup = append(processedGroup, m)
} else {
// Abort further processing in this group if one fails.
break
}
}
if len(processedGroup) > 0 {
mu.Lock()
orderedProcessed[groupID] = processedGroup
mu.Unlock()
}
}(groupID, groupMessages)
}
wg.Wait()
allProcessed := unorderedProcessed
for _, groupProcessed := range orderedProcessed {
allProcessed = append(allProcessed, groupProcessed...)
}
if len(allProcessed) > 0 {
c.batchDeleteMessages(ctx, allProcessed, queueURL)
}
}
// processSingleMessage handles processing of a single message with a maximum number of retries.
// It returns true if the message is processed successfully (so it can be deleted).
func (c *AwsSqsConsumer) processSingleMessage(ctx context.Context, msg types.Message) bool {
data, err := c.extractMessageData(&msg)
if err != nil {
c.common.log.Error().Err(err).Msg("failed to extract message data")
// Even on extraction errors, delete the message to prevent reprocessing.
return true
}
var retries int
var backoffDuration time.Duration
maxRetries := 3
for {
var processErr error
if c.config.PublicationDataMode.Enabled {
processErr = c.processPublicationDataMessage(ctx, msg, data)
} else {
processErr = c.processCommandMessage(ctx, msg, data)
}
if processErr == nil {
if retries > 0 {
c.common.log.Info().Msg("message processed successfully after retries")
}
break
}
// Only stop on our own cancellation: an unrelated context.Canceled bubbling up
// from the dispatcher must be retried rather than treated as shutdown.
if ctx.Err() != nil {
return false
}
retries++
if retries > maxRetries {
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Msg("max retries reached for processing message")
}
return false
}
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
c.common.log.Error().Err(processErr).Msg("error processing message, retrying")
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
select {
case <-time.After(backoffDuration):
case <-ctx.Done():
return false
}
}
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Inc()
return true
}
// batchDeleteMessages issues DeleteMessageBatch requests in batches of up to 10 messages.
// It logs failures from each batch deletion call.
func (c *AwsSqsConsumer) batchDeleteMessages(ctx context.Context, messages []types.Message, queueURL string) {
const maxBatchSize = 10
for start := 0; start < len(messages); start += maxBatchSize {
end := start + maxBatchSize
if end > len(messages) {
end = len(messages)
}
batch := messages[start:end]
entries := make([]types.DeleteMessageBatchRequestEntry, len(batch))
for i, msg := range batch {
entries[i] = types.DeleteMessageBatchRequestEntry{
Id: aws.String(aws.ToString(msg.MessageId)),
ReceiptHandle: msg.ReceiptHandle,
}
}
input := &sqs.DeleteMessageBatchInput{
QueueUrl: aws.String(queueURL),
Entries: entries,
}
output, err := c.client.DeleteMessageBatch(ctx, input)
if err != nil {
c.common.log.Error().Err(err).Msg("batch deletion failed")
continue
}
if len(output.Failed) > 0 {
for _, failed := range output.Failed {
c.common.log.Error().Msgf("failed to delete message ID %s: %s", aws.ToString(failed.Id), aws.ToString(failed.Message))
}
}
}
}
func getBoolAttributeValue(msg types.Message, key string) (bool, error) {
if attr, ok := msg.MessageAttributes[key]; ok && attr.StringValue != nil {
return strconv.ParseBool(*attr.StringValue)
}
return false, nil
}
func getUint64AttributeValue(msg types.Message, key string) (uint64, error) {
if attr, ok := msg.MessageAttributes[key]; ok && attr.StringValue != nil {
value, err := strconv.ParseUint(*attr.StringValue, 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse %s attribute: %w", key, err)
}
return value, nil
}
return 0, nil
}
// processPublicationDataMessage handles messages in publication data mode.
func (c *AwsSqsConsumer) processPublicationDataMessage(ctx context.Context, msg types.Message, data []byte) error {
channelsAttr := getMessageAttributeValue(msg, c.config.PublicationDataMode.ChannelsAttribute)
if channelsAttr == "" {
c.common.log.Info().Msg("no channels found, skipping message")
return nil
}
idempotencyKey := getMessageAttributeValue(msg, c.config.PublicationDataMode.IdempotencyKeyAttribute)
delta, err := getBoolAttributeValue(msg, c.config.PublicationDataMode.DeltaAttribute)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing delta attribute, skipping message")
return nil // Skip message on parsing error.
}
channels := strings.Split(channelsAttr, ",")
tags := publicationTagsFromMessageAttributes(msg, c.config.PublicationDataMode.TagsAttributePrefix)
version, err := getUint64AttributeValue(msg, c.config.PublicationDataMode.VersionAttribute)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing version attribute, skipping message")
return nil // Skip message on parsing error.
}
return c.dispatcher.DispatchPublication(ctx, channels, api.ConsumedPublication{
Data: data,
IdempotencyKey: idempotencyKey,
Delta: delta,
Tags: tags,
Version: version,
VersionEpoch: getMessageAttributeValue(msg, c.config.PublicationDataMode.VersionEpochAttribute),
})
}
// processCommandMessage handles non-publication messages.
func (c *AwsSqsConsumer) processCommandMessage(ctx context.Context, msg types.Message, data []byte) error {
method := getMessageAttributeValue(msg, c.config.MethodAttribute)
return c.dispatcher.DispatchCommand(ctx, method, data)
}
// extractMessageData returns the payload to be dispatched.
// If the provider is "sns", it decodes the SNS envelope.
func (c *AwsSqsConsumer) extractMessageData(msg *types.Message) ([]byte, error) {
if c.config.SNSEnvelope {
var envelope struct {
Message string `json:"Message"`
}
if msg.Body == nil {
return nil, errors.New("empty message body")
}
if err := json.Unmarshal([]byte(*msg.Body), &envelope); err != nil {
return nil, fmt.Errorf("failed to unmarshal SNS envelope: %w", err)
}
return []byte(envelope.Message), nil
}
if msg.Body == nil {
return nil, errors.New("empty message body")
}
return []byte(*msg.Body), nil
}
// getMessageAttributeValue extracts the string value of the given attribute key from the SQS message.
func getMessageAttributeValue(msg types.Message, key string) string {
if key == "" {
return ""
}
if attr, ok := msg.MessageAttributes[key]; ok && attr.StringValue != nil {
return *attr.StringValue
}
return ""
}
// publicationTagsFromMessageAttributes extracts tag values from message attributes using the given prefix.
func publicationTagsFromMessageAttributes(msg types.Message, prefix string) map[string]string {
var tags map[string]string
if prefix == "" {
return tags
}
for k, v := range msg.MessageAttributes {
if strings.HasPrefix(k, prefix) && v.StringValue != nil {
if tags == nil {
tags = make(map[string]string)
}
tags[strings.TrimPrefix(k, prefix)] = *v.StringValue
}
}
return tags
}

View File

@@ -0,0 +1,126 @@
//go:build integration
package consuming
import (
"context"
"errors"
"net/url"
"os"
"sync/atomic"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/aws/aws-sdk-go-v2/service/sqs/types"
endpoints "github.com/aws/smithy-go/endpoints"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
func TestAWSConsumerWithLocalStack(t *testing.T) {
t.Parallel()
ctx := context.Background()
// Set dummy credentials for LocalStack.
_ = os.Setenv("AWS_ACCESS_KEY_ID", "test")
_ = os.Setenv("AWS_SECRET_ACCESS_KEY", "test")
_ = os.Setenv("AWS_DEFAULT_REGION", "us-east-1")
defer func() {
_ = os.Unsetenv("AWS_ACCESS_KEY_ID")
_ = os.Unsetenv("AWS_SECRET_ACCESS_KEY")
_ = os.Unsetenv("AWS_DEFAULT_REGION")
}()
awsCfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
)
require.NoError(t, err)
parsedURL, _ := url.Parse("http://localhost:4566")
sqsOpts := []func(*sqs.Options){
sqs.WithEndpointResolverV2(overrideEndpointResolver{
Endpoint: endpoints.Endpoint{
URI: *parsedURL,
},
}),
}
sqsClient := sqs.NewFromConfig(awsCfg, sqsOpts...)
queueName := "test-queue-" + uuid.NewString()
createQueueOutput, err := sqsClient.CreateQueue(ctx, &sqs.CreateQueueInput{
QueueName: &queueName,
Attributes: map[string]string{},
})
require.NoError(t, err)
queueURL := *createQueueOutput.QueueUrl
cfg := AwsSqsConsumerConfig{
Region: "us-east-1",
Queues: []string{queueURL},
MaxNumberOfMessages: 10,
PollWaitTime: configtypes.Duration(2 * time.Second),
MethodAttribute: "Method",
LocalStackEndpoint: "http://localhost:4566",
}
var processedCount atomic.Int64
done1 := make(chan struct{})
done2 := make(chan struct{})
createConsumer := func(doneCh chan struct{}) *AwsSqsConsumer {
dispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
close(doneCh)
processedCount.Add(1)
return nil
},
}
consumer, err := NewAwsSqsConsumer(cfg, dispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
return consumer
}
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
go func() {
if err := createConsumer(done1).Run(ctx1); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("consumer1 error: %v", err)
}
}()
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
go func() {
if err := createConsumer(done2).Run(ctx2); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("consumer2 error: %v", err)
}
}()
_, err = sqsClient.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: &queueURL,
MessageBody: aws.String("Test message body"),
MessageAttributes: map[string]types.MessageAttributeValue{
"Method": {
DataType: aws.String("String"),
StringValue: aws.String("testMethod"),
},
},
})
require.NoError(t, err)
waitAnyCh(t, []chan struct{}{done1, done2}, 5*time.Second, "timeout waiting for message processing")
time.Sleep(500 * time.Millisecond)
require.Equal(t, int64(1), processedCount.Load(), "only one consumer must receive the message")
}

View File

@@ -0,0 +1,419 @@
package consuming
import (
"context"
"errors"
"fmt"
"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/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus"
)
type AzureServiceBusConsumerConfig = configtypes.AzureServiceBusConsumerConfig
// AzureServiceBusConsumer consumes messages from Azure Service Bus.
type AzureServiceBusConsumer struct {
config AzureServiceBusConsumerConfig
dispatcher Dispatcher
client *azservicebus.Client
common *consumerCommon
}
// NewAzureServiceBusConsumer creates and initializes a new AzureServiceBusConsumer.
func NewAzureServiceBusConsumer(
cfg AzureServiceBusConsumerConfig,
dispatcher Dispatcher,
common *consumerCommon,
) (*AzureServiceBusConsumer, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
var client *azservicebus.Client
var err error
if cfg.UseAzureIdentity {
cred, err := azidentity.NewClientSecretCredential(cfg.TenantID, cfg.ClientID, cfg.ClientSecret, nil)
if err != nil {
return nil, fmt.Errorf("failed to obtain Azure credential: %w", err)
}
client, err = azservicebus.NewClient(cfg.FullyQualifiedNamespace, cred, nil)
if err != nil {
return nil, fmt.Errorf("failed to create Azure Service Bus client: %w", err)
}
} else {
client, err = azservicebus.NewClientFromConnectionString(cfg.ConnectionString, nil)
if err != nil {
return nil, fmt.Errorf("failed to create client from connection string: %w", err)
}
}
return &AzureServiceBusConsumer{
config: cfg,
dispatcher: dispatcher,
client: client,
common: common,
}, nil
}
// Run starts the consumer until the context is canceled.
func (c *AzureServiceBusConsumer) Run(ctx context.Context) error {
if c.config.UseSessions {
return c.runSessionMode(ctx)
}
return c.runNonSessionMode(ctx)
}
// runSessionMode consumes messages in session mode using a separate goroutine per queue worker.
func (c *AzureServiceBusConsumer) runSessionMode(ctx context.Context) error {
var wg sync.WaitGroup
// For each queue, spawn workers.
for _, queue := range c.config.Queues {
for i := 0; i < c.config.MaxConcurrentCalls; i++ {
wg.Add(1)
go func(queueName string) {
defer wg.Done()
for {
// Respect context cancellation.
if ctx.Err() != nil {
return
}
sr, err := c.client.AcceptNextSessionForQueue(ctx, queueName, nil)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
c.common.log.Error().Err(err).Msgf("failed to accept session for queue %s", queueName)
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
// Process the accepted session.
c.processSession(ctx, sr, queueName)
}
}(queue)
}
}
wg.Wait()
return ctx.Err()
}
// processSession processes messages for a given session and renews the session lock periodically.
// The renewal interval is set to 2/3 of the remaining time until the session lock expires.
// The context passed to message processing is bound to the session lifetime.
func (c *AzureServiceBusConsumer) processSession(ctx context.Context, sr *azservicebus.SessionReceiver, queueName string) {
// Create a session-specific context that will be canceled when the session should end.
sessionCtx, cancelSession := context.WithCancel(ctx)
defer cancelSession()
// Derive a context for lock renewal from the session context.
renewCtx, cancelRenew := context.WithCancel(sessionCtx)
defer cancelRenew()
// Start a goroutine to renew the session lock at 2/3 of the remaining time until expiration.
go func() {
for {
select {
case <-renewCtx.Done():
return
default:
}
// Calculate remaining time until lock expiration.
lockUntil := sr.LockedUntil() // sr.LockedUntil() returns the time the session is locked until.
remaining := time.Until(lockUntil)
renewalInterval := time.Duration(float64(remaining) * (2.0 / 3.0))
if renewalInterval <= 0 {
c.common.log.Error().Msgf("session lock already expired for queue %s", queueName)
cancelSession()
return
}
// Wait for the computed renewal interval.
select {
case <-time.After(renewalInterval):
// Attempt to renew the session lock.
if err := sr.RenewSessionLock(renewCtx, nil); err != nil {
c.common.log.Error().Err(err).Msgf("failed to renew session lock for queue %s", queueName)
// On error, cancel the session to abort processing.
cancelSession()
return
} else {
c.common.log.Debug().Msgf("renewed session lock for queue %s", queueName)
}
case <-renewCtx.Done():
return
}
}
}()
// Ensure the session is closed when processing ends.
defer func() {
if err := sr.Close(sessionCtx); err != nil {
c.common.log.Error().Err(err).Msgf("failed to close session for queue %s", queueName)
}
}()
// Process messages using the session-bound context.
for {
if sessionCtx.Err() != nil {
return
}
messages, err := sr.ReceiveMessages(sessionCtx, c.config.MaxReceiveMessages, nil)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
c.common.log.Error().Err(err).Msgf("error receiving messages for session on queue %s", queueName)
return
}
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("queue", queueName).Int("num_messages", len(messages)).
Msg("received messages from queue")
}
if len(messages) == 0 {
// No messages – the session is ended.
return
}
for _, msg := range messages {
c.processMessage(sessionCtx, msg, sr)
}
}
}
// runNonSessionMode consumes messages without sessions.
func (c *AzureServiceBusConsumer) runNonSessionMode(ctx context.Context) error {
var wg sync.WaitGroup
// For each queue, spawn receivers.
for _, queue := range c.config.Queues {
for i := 0; i < c.config.MaxConcurrentCalls; i++ {
receiver, err := c.client.NewReceiverForQueue(queue, nil)
if err != nil {
return fmt.Errorf("failed to create receiver for queue %s: %w", queue, err)
}
wg.Add(1)
go func(r *azservicebus.Receiver, queueName string) {
defer wg.Done()
defer func() {
if err := r.Close(ctx); err != nil {
c.common.log.Error().Err(err).Msgf("failed to close receiver for queue %s", queueName)
}
}()
for {
if ctx.Err() != nil {
return
}
messages, err := r.ReceiveMessages(ctx, c.config.MaxReceiveMessages, nil)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
c.common.log.Error().Err(err).Msgf("error receiving messages from queue %s", queueName)
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("queue", queueName).Int("num_messages", len(messages)).
Msg("received messages from queue")
}
for _, msg := range messages {
c.processMessage(ctx, msg, r)
}
}
}(receiver, queue)
}
}
wg.Wait()
return ctx.Err()
}
// azureCompleter defines an interface for completing messages.
type azureCompleter interface {
CompleteMessage(context.Context, *azservicebus.ReceivedMessage, *azservicebus.CompleteMessageOptions) error
AbandonMessage(context.Context, *azservicebus.ReceivedMessage, *azservicebus.AbandonMessageOptions) error
}
// processMessage processes a single message with a retry mechanism.
func (c *AzureServiceBusConsumer) processMessage(ctx context.Context, msg *azservicebus.ReceivedMessage, completer azureCompleter) {
var retries int
var backoffDuration time.Duration
data := msg.Body
const maxRetries = 5
var processErr error
for {
if c.config.PublicationDataMode.Enabled {
processErr = c.processPublicationDataMessage(ctx, msg, data)
} else {
processErr = c.processCommandMessage(ctx, msg, data)
}
if processErr == nil {
if retries > 0 {
c.common.log.Info().Msg("message processed successfully after retries")
}
break
}
// Stop retrying if context is canceled or maximum attempts reached.
if ctx.Err() != nil || retries >= maxRetries {
c.common.log.Error().Err(processErr).Msg("max retries reached or context canceled; abandoning message")
break
}
retries++
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
c.common.log.Error().Err(processErr).Msgf("error processing message, retrying in %v", backoffDuration)
select {
case <-time.After(backoffDuration):
case <-ctx.Done():
return
}
}
if processErr != nil {
// Processing failed after retries: abandon the message so the broker
// redelivers it (and eventually dead-letters at MaxDeliveryCount) instead
// of completing it, which settles it as successful and permanently drops it.
if err := completer.AbandonMessage(ctx, msg, nil); err != nil {
c.common.log.Error().Err(err).Msg("failed to abandon message")
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
}
return
}
// Complete the message (or log an error on failure).
if err := completer.CompleteMessage(ctx, msg, nil); err != nil {
c.common.log.Error().Err(err).Msg("failed to complete message")
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
} else {
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Inc()
}
}
// processPublicationDataMessage handles messages in publication data mode.
func (c *AzureServiceBusConsumer) processPublicationDataMessage(ctx context.Context, msg *azservicebus.ReceivedMessage, data []byte) error {
idempotencyKey, _ := getProperty(msg, c.config.PublicationDataMode.IdempotencyKeyProperty)
delta, err := getAzureBoolProperty(msg, c.config.PublicationDataMode.DeltaProperty)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing delta property, skipping message")
return nil
}
channelsStr, _ := getProperty(msg, c.config.PublicationDataMode.ChannelsProperty)
channels := strings.Split(channelsStr, ",")
if len(channels) == 0 || (len(channels) == 1 && channels[0] == "") {
c.common.log.Info().Msg("no channels found, skipping message")
return nil
}
tags := getTagsFromProperties(msg, c.config.PublicationDataMode.TagsPropertyPrefix)
version, err := getAzureUint64Property(msg, c.config.PublicationDataMode.VersionProperty)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing version property, skipping message")
return nil
}
versionEpoch, _ := getProperty(msg, c.config.PublicationDataMode.VersionEpochProperty)
return c.dispatcher.DispatchPublication(ctx, channels, api.ConsumedPublication{
Data: data,
IdempotencyKey: idempotencyKey,
Delta: delta,
Tags: tags,
Version: version,
VersionEpoch: versionEpoch,
})
}
// processCommandMessage handles messages in command mode.
func (c *AzureServiceBusConsumer) processCommandMessage(ctx context.Context, msg *azservicebus.ReceivedMessage, data []byte) error {
method, _ := getProperty(msg, c.config.MethodProperty)
return c.dispatcher.DispatchCommand(ctx, method, data)
}
func getAzureUint64Property(msg *azservicebus.ReceivedMessage, key string) (uint64, error) {
if key == "" {
return 0, nil
}
val, ok := msg.ApplicationProperties[key]
if !ok {
return 0, nil
}
strVal, ok := val.(string)
if !ok {
return 0, fmt.Errorf("property %q is not a string", key)
}
uintVal, err := strconv.ParseUint(strVal, 10, 64)
if err != nil {
return 0, fmt.Errorf("error parsing uint64 property %q: %w", key, err)
}
return uintVal, nil
}
func getAzureBoolProperty(msg *azservicebus.ReceivedMessage, key string) (bool, error) {
if key == "" {
return false, nil
}
val, ok := msg.ApplicationProperties[key]
if !ok {
return false, nil
}
strVal, ok := val.(string)
if !ok {
return false, fmt.Errorf("property %q is not a string", key)
}
b, err := strconv.ParseBool(strVal)
if err != nil {
return false, fmt.Errorf("error parsing bool property %q: %w", key, err)
}
return b, nil
}
// getProperty extracts a string property from the message's ApplicationProperties.
func getProperty(msg *azservicebus.ReceivedMessage, key string) (string, bool) {
if key == "" {
return "", false
}
if val, ok := msg.ApplicationProperties[key]; ok {
if str, ok := val.(string); ok {
return str, true
}
}
return "", false
}
// getTagsFromProperties extracts tag values from message properties using the given prefix.
func getTagsFromProperties(msg *azservicebus.ReceivedMessage, prefix string) map[string]string {
var tags map[string]string
if prefix == "" {
return tags
}
for k, v := range msg.ApplicationProperties {
if strings.HasPrefix(k, prefix) {
if str, ok := v.(string); ok {
if tags == nil {
tags = make(map[string]string)
}
tags[strings.TrimPrefix(k, prefix)] = str
}
}
}
return tags
}

View File

@@ -0,0 +1,21 @@
package consuming
import (
"math/rand"
"time"
)
const (
minBackoffDuration = 10 * time.Millisecond
maxBackoffDuration = 5 * time.Second
)
func getNextBackoffDuration(currentBackoff time.Duration, retries int) time.Duration {
if currentBackoff >= maxBackoffDuration {
return maxBackoffDuration
}
//nolint:gosec // it's a jitter.
jitter := time.Duration(rand.Int63n(minBackoffDuration.Milliseconds())) * time.Millisecond
newBackoff := (minBackoffDuration + jitter) * (1 << retries)
return min(newBackoff, maxBackoffDuration)
}

View File

@@ -0,0 +1,79 @@
package consuming
import (
"context"
"fmt"
"github.com/centrifugal/centrifugo/v6/internal/api"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/service"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type ConsumerConfig = configtypes.Consumer
type Dispatcher interface {
DispatchCommand(ctx context.Context, method string, data []byte) error
DispatchPublication(ctx context.Context, channels []string, pub api.ConsumedPublication) error
}
type consumerCommon struct {
name string
log zerolog.Logger
nodeID string
}
func New(nodeID string, consumingHandler *api.ConsumingHandler, configs []ConsumerConfig) ([]service.Service, error) {
dispatcher := api.NewDispatcher(consumingHandler)
var services []service.Service
for _, config := range configs {
if !config.Enabled { // Important to keep this check inside specific type for proper config validation.
log.Info().
Str("consumer", config.Name).
Str("type", config.Type).
Msg("consumer is not enabled, skip")
continue
}
common := &consumerCommon{
name: config.Name,
log: log.With().Str("consumer", config.Name).Logger(),
nodeID: nodeID,
}
// Initialize consumer metrics with zero values.
metrics.InitConsumerMetrics(config.Name)
var consumer service.Service
var err error
switch config.Type {
case configtypes.ConsumerTypePostgres:
consumer, err = NewPostgresConsumer(config.Postgres, dispatcher, common)
case configtypes.ConsumerTypeKafka:
consumer, err = NewKafkaConsumer(config.Kafka, dispatcher, common)
case configtypes.ConsumerTypeNatsJetStream:
consumer, err = NewNatsJetStreamConsumer(config.NatsJetStream, dispatcher, common)
case configtypes.ConsumerTypeRedisStream:
consumer, err = NewRedisStreamConsumer(config.RedisStream, dispatcher, common)
case configtypes.ConsumerTypeGooglePubSub:
consumer, err = NewGooglePubSubConsumer(config.GooglePubSub, dispatcher, common)
case configtypes.ConsumerTypeAwsSqs:
consumer, err = NewAwsSqsConsumer(config.AwsSqs, dispatcher, common)
case configtypes.ConsumerTypeAzureServiceBus:
consumer, err = NewAzureServiceBusConsumer(config.AzureServiceBus, dispatcher, common)
default:
return nil, fmt.Errorf("unknown consumer type: %s", config.Type)
}
if err != nil {
return nil, fmt.Errorf("error initializing %s consumer (%s): %w", config.Type, config.Name, err)
}
services = append(services, consumer)
metrics.InitConsumerMetrics(config.Name)
log.Info().
Str("consumer", config.Name).
Str("type", config.Type).
Msg("running consumer")
}
return services, nil
}

View File

@@ -0,0 +1,31 @@
//go:build integration
package consuming
import (
"os"
"testing"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/rs/zerolog/log"
)
func TestMain(m *testing.M) {
// Initialize metrics with a custom registry for tests to avoid conflicts
registry := prometheus.NewRegistry()
_ = metrics.Init(metrics.Config{
Registerer: registry,
})
os.Exit(m.Run())
}
func testCommon(registry prometheus.Registerer) *consumerCommon {
return &consumerCommon{
name: "test",
nodeID: uuid.New().String(),
log: log.With().Str("consumer", "test").Logger(),
}
}

View File

@@ -0,0 +1,240 @@
package consuming
import (
"context"
"errors"
"fmt"
"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"
"cloud.google.com/go/pubsub/v2"
"google.golang.org/api/option"
)
type GooglePubSubConsumerConfig = configtypes.GooglePubSubConsumerConfig
// pubSubReceiveBackoffReset is the minimum duration a Receive call must run
// before a subsequent failure is treated as fresh, resetting the retry backoff.
const pubSubReceiveBackoffReset = 30 * time.Second
// GooglePubSubConsumer represents a Google Pub/Sub consumer.
type GooglePubSubConsumer struct {
config GooglePubSubConsumerConfig
dispatcher Dispatcher
client *pubsub.Client
common *consumerCommon
}
// NewGooglePubSubConsumer creates a new Google Pub/Sub consumer with the provided auth mechanism.
func NewGooglePubSubConsumer(
config GooglePubSubConsumerConfig, dispatcher Dispatcher, common *consumerCommon,
) (*GooglePubSubConsumer, error) {
if err := config.Validate(); err != nil {
return nil, err
}
var clientOpts []option.ClientOption
switch strings.ToLower(config.AuthMechanism) {
case "", "default":
// Use Application Default Credentials.
case "service_account":
if config.CredentialsFile == "" {
return nil, errors.New("credentials_file must be provided for service_account auth")
}
clientOpts = append(clientOpts, option.WithAuthCredentialsFile(option.ServiceAccount, config.CredentialsFile))
default:
return nil, fmt.Errorf("unsupported auth mechanism: %s", config.AuthMechanism)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := pubsub.NewClient(ctx, config.ProjectID, clientOpts...)
if err != nil {
return nil, fmt.Errorf("failed to create pubsub client: %w", err)
}
consumer := &GooglePubSubConsumer{
config: config,
dispatcher: dispatcher,
client: client,
common: common,
}
return consumer, nil
}
// Run starts the consumer and blocks until the provided context is canceled or an error occurs.
func (c *GooglePubSubConsumer) Run(ctx context.Context) error {
defer func() {
_ = c.client.Close()
}()
// For each subscription in the configuration, spawn a separate goroutine.
var wg sync.WaitGroup
for _, subID := range c.config.Subscriptions {
wg.Add(1)
go func(subscriptionID string) {
defer wg.Done()
sub := c.client.Subscriber(subscriptionID)
sub.ReceiveSettings.MaxOutstandingMessages = c.config.MaxOutstandingMessages
sub.ReceiveSettings.MaxOutstandingBytes = c.config.MaxOutstandingBytes
sub.ReceiveSettings.NumGoroutines = 10
// Receive re-establishes the streaming pull and returns only on ctx
// cancellation or an error the SDK deems non-retryable. Such an error is
// not necessarily permanent (IAM propagation, quota reset, backend
// failover, subscription recreated), and without re-subscribing the
// subscription would stay silently dead for the whole process lifetime.
// Re-Receive with a capped backoff so a genuinely permanent error can't
// spin hot.
var backoff time.Duration
var retries int
for ctx.Err() == nil {
start := time.Now()
err := sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("subscription", subID).
Msg("received message from subscription")
}
c.dispatchMessage(ctx, msg)
})
if err == nil || errors.Is(err, context.Canceled) || ctx.Err() != nil {
return
}
// A Receive that ran for a while before failing is a fresh failure,
// not an escalating one — reset the backoff ramp.
if time.Since(start) > pubSubReceiveBackoffReset {
backoff, retries = 0, 0
}
retries++
backoff = getNextBackoffDuration(backoff, retries)
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
c.common.log.Error().Err(err).Str("subscription", subscriptionID).
Dur("retry_in", backoff).Msg("error receiving messages, will re-subscribe")
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
}
}(subID)
}
wg.Wait()
return ctx.Err()
}
func (c *GooglePubSubConsumer) dispatchMessage(ctx context.Context, msg *pubsub.Message) {
c.processSingleMessage(ctx, msg)
}
func (c *GooglePubSubConsumer) processSingleMessage(ctx context.Context, msg *pubsub.Message) {
var err error
if c.config.PublicationDataMode.Enabled {
err = c.processPublicationDataMessage(ctx, msg)
} else {
err = c.processCommandMessage(ctx, msg)
}
if err == nil {
msg.Ack()
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Inc()
return
}
msg.Nack()
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
}
// processPublicationDataMessage handles messages in publication data mode.
func (c *GooglePubSubConsumer) processPublicationDataMessage(ctx context.Context, msg *pubsub.Message) error {
data := msg.Data
idempotencyKey := getAttributeValue(msg, c.config.PublicationDataMode.IdempotencyKeyAttribute)
delta, err := getGoogleBoolAttributeValue(msg, c.config.PublicationDataMode.DeltaAttribute)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing delta attribute, skipping message")
return nil // Skip message on parsing error.
}
channelsAttr := getAttributeValue(msg, c.config.PublicationDataMode.ChannelsAttribute)
channels := strings.Split(channelsAttr, ",")
if len(channels) == 0 || (len(channels) == 1 && channels[0] == "") {
c.common.log.Info().Msg("no channels found, skipping message")
return nil
}
tags := publicationTagsFromAttributes(msg, c.config.PublicationDataMode.TagsAttributePrefix)
version, err := getGoogleUint64AttributeValue(msg, c.config.PublicationDataMode.VersionAttribute)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing version attribute, skipping message")
return nil // Skip message on parsing error.
}
return c.dispatcher.DispatchPublication(ctx, channels, api.ConsumedPublication{
Data: data,
IdempotencyKey: idempotencyKey,
Delta: delta,
Tags: tags,
Version: version,
VersionEpoch: getAttributeValue(msg, c.config.PublicationDataMode.VersionEpochAttribute),
})
}
// processCommandMessage handles non-publication messages.
func (c *GooglePubSubConsumer) processCommandMessage(ctx context.Context, msg *pubsub.Message) error {
method := getAttributeValue(msg, c.config.MethodAttribute)
return c.dispatcher.DispatchCommand(ctx, method, msg.Data)
}
func getGoogleUint64AttributeValue(msg *pubsub.Message, key string) (uint64, error) {
if key == "" {
return 0, nil
}
val := msg.Attributes[key]
if val == "" {
return 0, nil
}
uintVal, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return 0, fmt.Errorf("error parsing attribute %s: %w", key, err)
}
return uintVal, nil
}
func getGoogleBoolAttributeValue(msg *pubsub.Message, key string) (bool, error) {
if key == "" {
return false, nil
}
val := msg.Attributes[key]
if val == "" {
return false, nil
}
return strconv.ParseBool(val)
}
// getAttributeValue extracts the value of the given attribute key from the message.
func getAttributeValue(msg *pubsub.Message, key string) string {
if key == "" {
return ""
}
return msg.Attributes[key]
}
// publicationTagsFromAttributes extracts tag values from message attributes using the given prefix.
func publicationTagsFromAttributes(msg *pubsub.Message, prefix string) map[string]string {
if prefix == "" {
return nil
}
var tags map[string]string
for k, v := range msg.Attributes {
if strings.HasPrefix(k, prefix) {
if tags == nil {
tags = make(map[string]string)
}
tags[strings.TrimPrefix(k, prefix)] = v
}
}
return tags
}

View File

@@ -0,0 +1,110 @@
//go:build integration
package consuming
import (
"context"
"fmt"
"os"
"sync"
"testing"
"time"
"cloud.google.com/go/pubsub/v2/apiv1/pubsubpb"
"google.golang.org/protobuf/types/known/durationpb"
"cloud.google.com/go/pubsub/v2"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
)
func TestGooglePubSubConsumer(t *testing.T) {
// Point the Pub/Sub client to the emulator.
_ = os.Setenv("PUBSUB_EMULATOR_HOST", "localhost:8085")
defer func() {
_ = os.Unsetenv("PUBSUB_EMULATOR_HOST")
}()
ctx := context.Background()
// Setup project, topic, and subscription IDs for testing.
projectID := "test-project-" + uuid.NewString()
topicID := "test-topic-" + uuid.NewString()
subscriptionID := "test-subscription-" + uuid.NewString()
// Create a Pub/Sub client (will connect to the emulator).
client, err := pubsub.NewClient(ctx, projectID)
if err != nil {
t.Fatalf("failed to create pubsub client: %v", err)
}
topic := &pubsubpb.Topic{
Name: fmt.Sprintf("projects/%s/topics/%s", projectID, topicID),
}
_, err = client.TopicAdminClient.CreateTopic(ctx, topic)
if err != nil {
t.Fatalf("failed to create topic: %v", err)
}
s := &pubsubpb.Subscription{
Name: fmt.Sprintf("projects/%s/subscriptions/%s", projectID, subscriptionID),
Topic: topic.Name,
TopicMessageRetentionDuration: durationpb.New(1 * time.Hour),
EnableMessageOrdering: true,
AckDeadlineSeconds: 20,
}
_, err = client.SubscriptionAdminClient.CreateSubscription(ctx, s)
if err != nil {
t.Fatalf("failed to create subscription: %v", err)
}
publisher := client.Publisher(topic.Name)
publisher.EnableMessageOrdering = true
// Publish a message with an ordering key.
orderingKey := "order-1"
result := publisher.Publish(ctx, &pubsub.Message{
Data: []byte("Hello, Pub/Sub with ordering!"),
OrderingKey: orderingKey,
Attributes: map[string]string{
"example": "value",
},
})
id, err := result.Get(ctx)
if err != nil {
t.Fatalf("failed to publish message: %v", err)
}
t.Logf("Published a message with ID: %s", id)
config := GooglePubSubConsumerConfig{
ProjectID: projectID,
Subscriptions: []string{subscriptionID},
MaxOutstandingMessages: 10,
}
done := make(chan struct{})
// Ensure done is closed only once. PUB/SUB emulator we use in tests is a bit aggressive
// in sending duplicate messages.
var closeDoneOnce sync.Once
dispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
closeDoneOnce.Do(func() {
close(done)
})
return nil
},
}
consumer, err := NewGooglePubSubConsumer(config, dispatcher, testCommon(prometheus.NewRegistry()))
if err != nil {
t.Fatalf("failed to create consumer: %v", err)
}
go func() {
if err := consumer.Run(ctx); err != nil {
t.Errorf("consumer error: %v", err)
}
}()
waitCh(t, done, 5*time.Second, "timeout waiting for message processing")
}

934
internal/consuming/kafka.go Normal file
View File

@@ -0,0 +1,934 @@
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,403 @@
package consuming
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"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/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// NatsJetStreamConsumerConfig is an alias for our configuration type.
type NatsJetStreamConsumerConfig = configtypes.NatsJetStreamConsumerConfig
// natsConsumeBackoffReset is the minimum duration a consume session must run
// before its end resets the recreation backoff, so rapid flapping still backs off.
const natsConsumeBackoffReset = 30 * time.Second
// NatsJetStreamConsumer consumes messages from NATS JetStream.
type NatsJetStreamConsumer struct {
config NatsJetStreamConsumerConfig
dispatcher Dispatcher
nc *nats.Conn
consumer jetstream.Consumer
ctx context.Context
common *consumerCommon
consumeContext jetstream.ConsumeContext
// recreateCh signals that the consumer should be re-created (e.g. on heartbeat loss).
recreateCh chan struct{}
}
// NewNatsJetStreamConsumer creates a new NatsJetStreamConsumer instance.
// On startup if creating a consumer fails then an error is returned.
// Later at runtime, if a heartbeat error occurs or the consumer/stream is
// deleted on the server side the consumer is re-created endlessly.
func NewNatsJetStreamConsumer(
cfg NatsJetStreamConsumerConfig,
dispatcher Dispatcher,
common *consumerCommon,
) (*NatsJetStreamConsumer, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
c := &NatsJetStreamConsumer{
config: cfg,
dispatcher: dispatcher,
common: common,
recreateCh: make(chan struct{}, 1), // buffered so duplicate signals are dropped
}
opts := []nats.Option{
nats.MaxReconnects(-1),
nats.ConnectHandler(func(conn *nats.Conn) {
c.common.log.Info().Msg("connected")
}),
nats.ReconnectHandler(func(conn *nats.Conn) {
c.common.log.Info().Msg("reconnected")
}),
nats.DisconnectErrHandler(func(conn *nats.Conn, err error) {
c.common.log.Warn().Err(err).Msg("disconnected")
}),
}
switch {
case cfg.CredentialsFile != "":
opts = append(opts, nats.UserCredentials(cfg.CredentialsFile))
case cfg.Username != "":
opts = append(opts, nats.UserInfo(cfg.Username, cfg.Password))
case cfg.Token != "":
opts = append(opts, nats.Token(cfg.Token))
}
if cfg.TLS.Enabled {
tlsConfig, err := cfg.TLS.ToGoTLSConfig("nats_jetstream")
if err != nil {
return nil, fmt.Errorf("failed to create TLS config: %w", err)
}
opts = append(opts, nats.Secure(tlsConfig))
}
nc, err := nats.Connect(cfg.URL, opts...)
if err != nil {
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
c.nc = nc
createCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
jsConsumer, err := createJetStreamConsumer(createCtx, nc, cfg)
if err != nil {
nc.Close()
return nil, fmt.Errorf("failed to create JetStream consumer: %w", err)
}
c.consumer = jsConsumer
return c, nil
}
// createJetStreamConsumer is a helper (used both at startup and runtime)
// to create a new JetStream consumer based on the configuration.
func createJetStreamConsumer(ctx context.Context, nc *nats.Conn, cfg NatsJetStreamConsumerConfig) (jetstream.Consumer, error) {
js, err := jetstream.New(nc)
if err != nil {
return nil, fmt.Errorf("failed to create JetStream context: %w", err)
}
if cfg.UseExistingConsumer {
// If UseExistingConsumer is enabled, just look up the existing consumer by DurableConsumerName
consumer, err := js.Consumer(ctx, cfg.StreamName, cfg.DurableConsumerName)
if err != nil {
return nil, fmt.Errorf("failed to lookup existing consumer %q: %w", cfg.DurableConsumerName, err)
}
return consumer, nil
}
// By default, create or update the consumer
deliverPolicy := jetstream.DeliverNewPolicy
if cfg.DeliverPolicy == "all" {
deliverPolicy = jetstream.DeliverAllPolicy
}
return js.CreateOrUpdateConsumer(ctx, cfg.StreamName, jetstream.ConsumerConfig{
Name: cfg.DurableConsumerName,
Durable: cfg.DurableConsumerName,
FilterSubjects: cfg.Subjects,
DeliverPolicy: deliverPolicy,
AckWait: 30 * time.Second,
MaxAckPending: cfg.MaxAckPending,
})
}
// createJetStreamConsumer creates a new consumer using the instance's connection and config.
// This method is used for runtime re-creation.
func (c *NatsJetStreamConsumer) createJetStreamConsumer(ctx context.Context) (jetstream.Consumer, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return createJetStreamConsumer(ctx, c.nc, c.config)
}
// startConsume calls Consume on the current consumer.
func (c *NatsJetStreamConsumer) startConsume(_ context.Context) error {
consumeContext, err := c.consumer.Consume(
c.msgHandler,
jetstream.ConsumeErrHandler(c.errorHandler()),
jetstream.PullHeartbeat(5*time.Second),
)
if err != nil {
return err
}
c.consumeContext = consumeContext
return nil
}
// triggerRecreation signals that the consumer should be re-created.
func (c *NatsJetStreamConsumer) triggerRecreation() {
select {
case c.recreateCh <- struct{}{}:
default:
// Already signaled, do nothing.
}
}
// msgHandler is the callback for incoming JetStream messages.
func (c *NatsJetStreamConsumer) msgHandler(msg jetstream.Msg) {
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("subject", msg.Subject()).Msg("received message from subject")
}
var processErr error
if c.config.PublicationDataMode.Enabled {
processErr = c.processPublicationDataMessage(msg)
} else {
processErr = c.processCommandMessage(msg)
}
if processErr == nil {
if err := msg.Ack(); err != nil {
c.common.log.Error().Err(err).Msg("failed to ack message")
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
} else {
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Inc()
}
} else {
c.common.log.Error().Err(processErr).Msg("processing message failed")
// NakWithDelay, not Nak: a bare Nak triggers instant redelivery with no
// backoff and the consumer sets no MaxDeliver, so a persistently failing
// (poison) message or a downstream outage would spin in a tight
// redeliver->fail->redeliver loop, pinning CPU and blocking head-of-line
// progress. Delay grows with the delivery count, capped like the other
// consumers' backoff.
if err := msg.NakWithDelay(natsNakDelay(msg)); err != nil {
c.common.log.Error().Err(err).Msg("failed to nak message")
}
}
}
// natsNakDelay computes an exponential redelivery backoff (capped) from the
// message's delivery count.
func natsNakDelay(msg jetstream.Msg) time.Duration {
retries := 1
if md, err := msg.Metadata(); err == nil && md.NumDelivered > 0 {
retries = int(md.NumDelivered)
if retries > 10 { // cap to avoid 1<<retries overflow / overlong delays
retries = 10
}
}
return getNextBackoffDuration(0, retries)
}
// processPublicationDataMessage processes a message in publication data mode.
func (c *NatsJetStreamConsumer) processPublicationDataMessage(msg jetstream.Msg) error {
idempotencyKey := getNatsHeaderValue(msg, c.config.PublicationDataMode.IdempotencyKeyHeader)
delta, err := getNatsBoolHeaderValue(msg, c.config.PublicationDataMode.DeltaHeader)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing delta header, skipping message")
return nil
}
channels := strings.Split(getNatsHeaderValue(msg, c.config.PublicationDataMode.ChannelsHeader), ",")
if len(channels) == 0 || (len(channels) == 1 && channels[0] == "") {
c.common.log.Info().Msg("no channels found, skipping message")
return nil
}
tags := publicationTagsFromNatsHeaders(msg, c.config.PublicationDataMode.TagsHeaderPrefix)
version, err := getNatsUint64HeaderValue(msg, c.config.PublicationDataMode.VersionHeader)
if err != nil {
c.common.log.Error().Err(err).Msg("error parsing version header, skipping message")
return nil
}
return c.dispatcher.DispatchPublication(c.ctx, channels, api.ConsumedPublication{
Data: msg.Data(),
IdempotencyKey: idempotencyKey,
Delta: delta,
Tags: tags,
Version: version,
VersionEpoch: getNatsHeaderValue(msg, c.config.PublicationDataMode.VersionEpochHeader),
})
}
// processCommandMessage processes a command message.
func (c *NatsJetStreamConsumer) processCommandMessage(msg jetstream.Msg) error {
method := getNatsHeaderValue(msg, c.config.MethodHeader)
return c.dispatcher.DispatchCommand(c.ctx, method, msg.Data())
}
func getNatsUint64HeaderValue(msg jetstream.Msg, key string) (uint64, error) {
if key == "" {
return 0, nil
}
val := msg.Headers().Get(key)
if val == "" {
return 0, nil
}
i, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return 0, fmt.Errorf("error parsing header %q: %w", key, err)
}
return i, nil
}
func getNatsBoolHeaderValue(msg jetstream.Msg, key string) (bool, error) {
if key == "" {
return false, nil
}
val := msg.Headers().Get(key)
if val == "" {
return false, nil
}
b, err := strconv.ParseBool(val)
if err != nil {
return false, fmt.Errorf("error parsing header %q: %w", key, err)
}
return b, nil
}
// getNatsHeaderValue retrieves a header value from the NATS message.
func getNatsHeaderValue(msg jetstream.Msg, key string) string {
if key == "" {
return ""
}
return msg.Headers().Get(key)
}
// publicationTagsFromNatsHeaders extracts tags from message headers using the given prefix.
func publicationTagsFromNatsHeaders(msg jetstream.Msg, prefix string) map[string]string {
var tags map[string]string
if prefix == "" {
return tags
}
for key, vals := range msg.Headers() {
if strings.HasPrefix(key, prefix) && len(vals) > 0 {
if tags == nil {
tags = make(map[string]string)
}
tags[strings.TrimPrefix(key, prefix)] = vals[0]
}
}
return tags
}
// errorHandler returns a jetstream error handler that triggers recreation on heartbeat loss
// or when the consumer (or its stream) was deleted on the server side. The latter happens
// when JetStream state is lost externally – ex. stream removed and re-created by an operator,
// or in-memory stream lost after NATS restart. Without recreation Centrifugo would keep
// logging errors without consuming anything until the process is restarted.
func (c *NatsJetStreamConsumer) errorHandler() jetstream.ConsumeErrHandlerFunc {
return func(consumeCtx jetstream.ConsumeContext, err error) {
if errors.Is(err, jetstream.ErrNoHeartbeat) ||
errors.Is(err, jetstream.ErrConsumerDeleted) ||
errors.Is(err, jetstream.ErrConsumerNotFound) ||
errors.Is(err, jetstream.ErrStreamNotFound) {
c.common.log.Warn().Err(err).Msg("consumer unavailable, triggering consumer recreation")
c.triggerRecreation()
return
}
c.common.log.Error().Err(err).Msg("error during consuming")
}
}
// Run starts the consumer and blocks until the context is canceled.
// If a heartbeat error occurs then the consumer is re-created
// and the consumeContext re-established with endless retries.
func (c *NatsJetStreamConsumer) Run(ctx context.Context) error {
c.ctx = ctx
defer func() {
_ = c.nc.Drain()
}()
retries := 0
var backoffDuration time.Duration
firstRun := true
for {
// For subsequent runs, attempt to recreate the consumer.
if !firstRun {
for {
newConsumer, err := c.createJetStreamConsumer(ctx)
if err == nil {
c.consumer = newConsumer
c.common.log.Info().Msg("consumer recreation succeeded")
break
}
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
retries++
c.common.log.Error().Err(err).Str("delay", backoffDuration.String()).
Msg("failed to recreate consumer, retrying")
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoffDuration):
}
}
}
// Start the ConsumeContext.
if err := c.startConsume(ctx); err != nil {
if firstRun {
// On initial startup, return error immediately.
return fmt.Errorf("error on start consuming: %w", err)
}
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
retries++
c.common.log.Error().Err(err).Str("delay", backoffDuration.String()).
Msg("failed to start consumer consumeContext, retrying")
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoffDuration):
}
continue
}
firstRun = false
startedAt := time.Now()
c.common.log.Info().Msg("consuming started, waiting for messages")
// Block until context cancellation or a recreation signal is received.
select {
case <-ctx.Done():
c.consumeContext.Stop()
return ctx.Err()
case <-c.recreateCh:
c.common.log.Info().Msg("recreating consumer")
c.consumeContext.Stop()
// Recreate the consumer.
}
// Reset the backoff ramp only if the session ran healthily for a while. A
// session that starts fine but dies almost immediately (flapping) keeps the
// ramp so the recreation loop backs off instead of spinning, rather than
// resetting to zero on every short-lived start.
if time.Since(startedAt) > natsConsumeBackoffReset {
retries = 0
backoffDuration = 0
}
}
}

View File

@@ -0,0 +1,210 @@
//go:build integration
package consuming
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/nats-io/nats.go"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
func TestNatsJetStreamConsumer(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
useExistingConsumer bool
}{
{name: "basic", useExistingConsumer: false},
{name: "use_existing_consumer", useExistingConsumer: true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testNatsJetStreamConsumer(t, tc.useExistingConsumer)
})
}
}
func testNatsJetStreamConsumer(t *testing.T, useExistingConsumer bool) {
url := "nats://localhost:4222"
subject := "test.subject." + uuid.NewString()
durableConsumerName := "test-durable-" + uuid.NewString()
nc, err := nats.Connect(url)
require.NoError(t, err)
defer nc.Close()
js, err := nc.JetStream()
require.NoError(t, err)
streamName := "TEST_STREAM_" + uuid.NewString()
_, err = js.AddStream(&nats.StreamConfig{
Name: streamName,
Subjects: []string{subject},
Storage: nats.MemoryStorage,
})
require.NoError(t, err)
// If useExistingConsumer is true, create the consumer manually first
if useExistingConsumer {
_, err = js.AddConsumer(streamName, &nats.ConsumerConfig{
Durable: durableConsumerName,
Name: durableConsumerName,
})
require.NoError(t, err)
}
var receivedNum atomic.Int64
createConsumer := func(name string, done chan struct{}) *NatsJetStreamConsumer {
dispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
receivedNum.Add(int64(1))
close(done)
return nil
},
}
cfg := NatsJetStreamConsumerConfig{
URL: url,
StreamName: streamName,
Subjects: []string{subject},
DurableConsumerName: durableConsumerName, // shared durable name
DeliverPolicy: "new",
MethodHeader: "test-method",
UseExistingConsumer: useExistingConsumer,
}
consumer, err := NewNatsJetStreamConsumer(cfg, dispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
return consumer
}
// Create done channels
done1 := make(chan struct{})
done2 := make(chan struct{})
// Start both consumers
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
consumer1 := createConsumer("consumer1", done1)
go func() {
if err := consumer1.Run(ctx1); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("consumer1 error: %v", err)
}
}()
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
consumer2 := createConsumer("consumer2", done2)
go func() {
if err := consumer2.Run(ctx2); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("consumer2 error: %v", err)
}
}()
// Publish a message
msg := nats.NewMsg(subject)
msg.Data = []byte("Hello from dual consumers!")
msg.Header.Set("test-method", "publish")
_, err = js.PublishMsg(msg)
require.NoError(t, err)
waitAnyCh(t, []chan struct{}{done1, done2}, 5*time.Second, "timeout waiting for message processing")
time.Sleep(500 * time.Millisecond) // Give a short delay in case both try to ack
require.Equal(t, int64(1), receivedNum.Load(), "only one consumer should have received the message")
}
// TestNatsJetStreamConsumer_RecreateOnConsumerDeleted verifies that the consumer
// recovers without a process restart when its JetStream consumer is lost on the
// server side – ex. the stream is deleted and re-created by an operator, or an
// in-memory stream is lost after NATS restart.
func TestNatsJetStreamConsumer_RecreateOnConsumerDeleted(t *testing.T) {
t.Parallel()
url := "nats://localhost:4222"
subject := "test.recreate." + uuid.NewString()
durableConsumerName := "test-durable-" + uuid.NewString()
streamName := "TEST_STREAM_" + uuid.NewString()
nc, err := nats.Connect(url)
require.NoError(t, err)
defer nc.Close()
js, err := nc.JetStream()
require.NoError(t, err)
addStream := func() {
_, err := js.AddStream(&nats.StreamConfig{
Name: streamName,
Subjects: []string{subject},
Storage: nats.MemoryStorage,
})
require.NoError(t, err)
}
addStream()
var receivedNum atomic.Int64
dispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
receivedNum.Add(1)
return nil
},
}
cfg := NatsJetStreamConsumerConfig{
URL: url,
StreamName: streamName,
Subjects: []string{subject},
DurableConsumerName: durableConsumerName,
DeliverPolicy: "new",
MethodHeader: "test-method",
}
consumer, err := NewNatsJetStreamConsumer(cfg, dispatcher, testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
if err := consumer.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("consumer error: %v", err)
}
}()
publishAndWaitReceived := func(stage string) {
deadline := time.Now().Add(30 * time.Second)
before := receivedNum.Load()
for time.Now().Before(deadline) {
msg := nats.NewMsg(subject)
msg.Data = []byte("hello")
msg.Header.Set("test-method", "publish")
_, _ = js.PublishMsg(msg)
time.Sleep(300 * time.Millisecond)
if receivedNum.Load() > before {
return
}
}
t.Fatalf("timeout waiting for message processing: %s", stage)
}
// Sanity check: consumer works initially.
publishAndWaitReceived("initial consume")
// Delete the stream: the durable consumer is destroyed together with it.
require.NoError(t, js.DeleteStream(streamName))
// Give the consumer some time to observe the loss.
time.Sleep(2 * time.Second)
// Re-create the stream (as an operator like NACK would do). The consumer
// must re-create its durable and resume consuming without a restart.
addStream()
publishAndWaitReceived("consume after stream re-creation")
}

View File

@@ -0,0 +1,340 @@
package consuming
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup"
)
const (
defaultNumPartitions = 1
defaultPartitionSelectLimit = 100
)
func NewPostgresConsumer(
config PostgresConfig, dispatcher Dispatcher, common *consumerCommon,
) (*PostgresConsumer, error) {
if config.DSN == "" {
return nil, errors.New("dsn is required")
}
if config.OutboxTableName == "" {
return nil, errors.New("outbox_table_name is required")
}
if config.NumPartitions == 0 {
config.NumPartitions = defaultNumPartitions
}
if config.PartitionSelectLimit == 0 {
config.PartitionSelectLimit = defaultPartitionSelectLimit
}
if config.PartitionPollInterval == 0 {
config.PartitionPollInterval = configtypes.Duration(300 * time.Millisecond)
}
conf, err := pgxpool.ParseConfig(config.DSN)
if err != nil {
return nil, fmt.Errorf("error parsing postgresql DSN: %w", err)
}
if config.TLS.Enabled {
tlsConfig, err := config.TLS.ToGoTLSConfig("postgresql:" + common.name)
if err != nil {
return nil, fmt.Errorf("error creating postgresql TLS config: %w", err)
}
conf.ConnConfig.TLSConfig = tlsConfig
}
pool, err := pgxpool.NewWithConfig(context.Background(), conf)
if err != nil {
return nil, fmt.Errorf("error creating postgresql pool: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = pool.Ping(ctx)
if err != nil {
return nil, err
}
c := &PostgresConsumer{
pool: pool,
dispatcher: dispatcher,
config: config,
lockPrefix: "centrifugo_partition_lock_" + common.name,
common: common,
}
if config.PartitionNotificationChannel != "" && config.PartitionNotificationDSN != "" {
nCfg, err := pgxpool.ParseConfig(config.PartitionNotificationDSN)
if err != nil {
pool.Close()
return nil, fmt.Errorf("error parsing partition notification DSN: %w", err)
}
nCfg.MaxConns = 1
nPool, err := pgxpool.NewWithConfig(context.Background(), nCfg)
if err != nil {
pool.Close()
return nil, fmt.Errorf("error creating partition notification pool: %w", err)
}
c.notifyPool = nPool
}
return c, nil
}
type PostgresConfig = configtypes.PostgresConsumerConfig
type PostgresConsumer struct {
pool *pgxpool.Pool
notifyPool *pgxpool.Pool // Dedicated single-conn pool for LISTEN; nil = use pool
config PostgresConfig
dispatcher Dispatcher
lockPrefix string
common *consumerCommon
}
type PostgresEvent struct {
ID int64
Method string
Payload []byte
Partition int64
}
func (c *PostgresConsumer) listenForNotifications(ctx context.Context, triggerChannels []chan struct{}) error {
pool := c.pool
if c.notifyPool != nil {
pool = c.notifyPool
}
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("error acquiring connection: %w", err)
}
defer conn.Release()
_, err = conn.Exec(ctx, "LISTEN "+pgx.Identifier{c.config.PartitionNotificationChannel}.Sanitize())
if err != nil {
return fmt.Errorf("error executing LISTEN command: %w", err)
}
for {
notification, err := conn.Conn().WaitForNotification(ctx)
if err != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
return fmt.Errorf("error waiting postgresql notifications: %w", err)
}
}
partition, err := strconv.Atoi(notification.Payload)
if err != nil {
c.common.log.Error().Err(err).Msg("error converting postgresql notification")
continue
}
if partition < 0 || partition > len(triggerChannels)-1 {
c.common.log.Error().Int("partition", partition).Msg("outbox partition is out of range")
continue
}
select {
case triggerChannels[partition] <- struct{}{}:
default:
}
}
}
var ErrLockNotAcquired = errors.New("advisory lock not acquired")
func (c *PostgresConsumer) processOnce(ctx context.Context, partition int) (int, error) {
tx, err := c.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
if err != nil {
return 0, fmt.Errorf("error starting transaction: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Acquire an advisory lock for partition. This allows us to process all the rows
// from partition in order.
lockName := c.lockPrefix + strconv.Itoa(partition)
if c.config.UseTryLock {
var acquired bool
err = tx.QueryRow(ctx, "SELECT pg_try_advisory_xact_lock(hashtext($1))", lockName).Scan(&acquired)
if err != nil {
return 0, fmt.Errorf("error acquiring advisory lock: %w", err)
}
if !acquired {
return 0, ErrLockNotAcquired
}
} else {
_, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtext($1))", lockName)
if err != nil {
return 0, fmt.Errorf("error acquiring advisory lock: %w", err)
}
}
sql := `
SELECT
id, method, payload, partition
FROM %s
WHERE partition=$1
ORDER BY id ASC
LIMIT $2`
rows, err := tx.Query(ctx, fmt.Sprintf(sql, c.config.OutboxTableName), partition, c.config.PartitionSelectLimit)
if err != nil {
return 0, fmt.Errorf("error selecting outbox events: %w", err)
}
defer rows.Close()
var events []PostgresEvent
numProcessedRows := 0
for rows.Next() {
var event PostgresEvent
err := rows.Scan(&event.ID, &event.Method, &event.Payload, &event.Partition)
if err != nil {
return 0, fmt.Errorf("error scanning event: %w", err)
}
events = append(events, event)
}
err = rows.Err()
if err != nil {
return 0, fmt.Errorf("rows error: %w", err)
}
idsToDelete := make([]int64, 0, len(events))
var dispatchErr error
for _, event := range events {
dispatchErr = c.dispatcher.DispatchCommand(ctx, event.Method, event.Payload)
if dispatchErr != nil {
// Stop here, all processed events will be removed, and we will start from this one.
c.common.log.Error().Err(dispatchErr).Str("method", event.Method).Msg("error processing consumed event")
break
} else {
numProcessedRows++
idsToDelete = append(idsToDelete, event.ID)
}
}
// Delete processed events.
if len(idsToDelete) > 0 {
_, err = tx.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE id = ANY($1)", c.config.OutboxTableName), idsToDelete)
if err != nil {
return 0, fmt.Errorf("error deleting outbox events: %w", err)
}
}
err = tx.Commit(ctx)
if err != nil {
return 0, fmt.Errorf("error committing transaction: %w", err)
}
return numProcessedRows, dispatchErr
}
func (c *PostgresConsumer) Run(ctx context.Context) error {
defer c.pool.Close()
if c.notifyPool != nil {
defer c.notifyPool.Close()
}
eg, ctx := errgroup.WithContext(ctx)
// Process outbox faster using LISTEN/NOTIFY.
partitionTriggerChannels := make([]chan struct{}, c.config.NumPartitions)
for i := 0; i < c.config.NumPartitions; i++ {
partitionTriggerChannels[i] = make(chan struct{}, 1)
}
if c.config.PartitionNotificationChannel != "" {
eg.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
err := c.listenForNotifications(ctx, partitionTriggerChannels)
if err != nil {
if errors.Is(err, context.Canceled) {
return ctx.Err()
}
c.common.log.Error().Err(err).Msg("error listening outbox notifications")
select {
case <-time.After(time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
}
})
}
pollInterval := c.config.PartitionPollInterval
for i := 0; i < c.config.NumPartitions; i++ {
i := i
eg.Go(func() error {
var backoffDuration time.Duration = 0
retries := 0
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
numRows, err := c.processOnce(ctx, i)
if err != nil {
if errors.Is(err, context.Canceled) {
return err
}
if c.config.UseTryLock && errors.Is(err, ErrLockNotAcquired) {
// If we are using try advisory lock, and it was not acquired
// then wait for notification or poll interval.
select {
case <-time.After(pollInterval.ToDuration()):
case <-partitionTriggerChannels[i]:
case <-ctx.Done():
return ctx.Err()
}
continue
}
retries++
backoffDuration = getNextBackoffDuration(backoffDuration, retries)
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
c.common.log.Error().Err(err).Int("partition", i).Msg("error processing postgresql outbox")
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoffDuration):
continue
}
}
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Add(float64(numRows))
retries = 0
backoffDuration = 0
if numRows < c.config.PartitionSelectLimit {
// Sleep until poll interval or notification for events in partition.
// If worker processed rows equal to the limit then we don't want to sleep
// here as there could be more events in the table potentially.
// If worker processed less than a limit events - then it means table
// is empty now or some events
select {
case <-time.After(pollInterval.ToDuration()):
case <-partitionTriggerChannels[i]:
case <-ctx.Done():
return ctx.Err()
}
}
}
})
}
return eg.Wait()
}

View File

@@ -0,0 +1,384 @@
//go:build integration
package consuming
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
const (
testPGDSN = "postgres://test:test@localhost:5432/test"
)
// setupTestTable creates a test table and notification trigger in the PostgreSQL database.
func setupTestTable(ctx context.Context, tableName string, notificationChannel string) error {
pool, err := pgxpool.New(ctx, testPGDSN)
if err != nil {
return fmt.Errorf("failed to create pool: %w", err)
}
defer pool.Close()
// Define the SQL for creating the table
createTableSQL := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
id BIGSERIAL PRIMARY KEY,
method text NOT NULL,
payload BYTEA NOT NULL,
partition INTEGER NOT NULL
);`, tableName)
_, err = pool.Exec(ctx, createTableSQL)
if err != nil {
return fmt.Errorf("failed to create test table: %w", err)
}
// Define the SQL for creating the notification trigger.
createTriggerSQL := fmt.Sprintf(`
CREATE OR REPLACE FUNCTION %s()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('%s', NEW.partition::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER %s
AFTER INSERT ON %s
FOR EACH ROW
EXECUTE FUNCTION %s();`, notificationChannel, notificationChannel, notificationChannel, tableName, notificationChannel)
_, err = pool.Exec(ctx, createTriggerSQL)
if err != nil {
return fmt.Errorf("failed to create notification trigger: %w", err)
}
return nil
}
// testPostgresCommon returns consumerCommon with the given name. Advisory lock keys are
// derived from consumer name and PostgreSQL advisory locks are database-wide, so tests
// running in parallel must use distinct names to not block each other.
func testPostgresCommon(name string) *consumerCommon {
common := testCommon(prometheus.NewRegistry())
common.name = name
return common
}
func insertEvent(ctx context.Context, pool *pgxpool.Pool, tableName string, method string, payload []byte, partition int) error {
_, err := pool.Exec(ctx, "INSERT INTO "+tableName+" (method, payload, partition) VALUES ($1, $2, $3)", method, payload, partition)
return err
}
func getEventCount(ctx context.Context, tableName string, partition int) (int, error) {
pool, err := pgxpool.New(ctx, testPGDSN)
if err != nil {
return 0, fmt.Errorf("failed to create pool: %w", err)
}
defer pool.Close()
var count int
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE partition=$1", tableName)
err = pool.QueryRow(ctx, query, partition).Scan(&count)
if err != nil {
return 0, fmt.Errorf("error querying count: %w", err)
}
return count, nil
}
func ensureEventsRemoved(ctx context.Context, t *testing.T, tableName string, partition int) {
t.Helper()
for {
select {
case <-ctx.Done():
require.Fail(t, "timeout checking removed events")
default:
eventCount, err := getEventCount(ctx, tableName, partition)
require.NoError(t, err)
if eventCount != 0 {
select {
case <-ctx.Done():
require.Fail(t, "timeout checking removed events")
case <-time.After(time.Second):
continue
}
}
return
}
}
}
func TestPostgresConsumer_GreenScenario(t *testing.T) {
t.Parallel()
testCases := []struct {
useTryLock bool
}{
{useTryLock: false},
{useTryLock: true},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("useTryLock=%v", tc.useTryLock), func(t *testing.T) {
testTableName := "centrifugo_consumer_test_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testNotificationChannel := "centrifugo_test_channel_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testMethod := "method"
testPayload := []byte(`{"key":"value"}`)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := setupTestTable(ctx, testTableName, testNotificationChannel)
require.NoError(t, err)
eventReceived := make(chan struct{})
consumerClosed := make(chan struct{})
// Setup consumer
config := PostgresConfig{
DSN: testPGDSN,
OutboxTableName: testTableName,
PartitionSelectLimit: 10,
NumPartitions: 1,
PartitionPollInterval: configtypes.Duration(300 * time.Millisecond),
PartitionNotificationChannel: testNotificationChannel,
UseTryLock: tc.useTryLock,
}
consumer, err := NewPostgresConsumer(config, &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
require.Equal(t, testMethod, method)
require.Equal(t, testPayload, data)
close(eventReceived)
return nil
},
}, testPostgresCommon(testTableName))
require.NoError(t, err)
// Start the consumer
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
partition := 0
err = insertEvent(ctx, consumer.pool, testTableName, testMethod, testPayload, partition)
require.NoError(t, err)
waitCh(t, eventReceived, 30*time.Second, "timeout waiting for event")
ensureEventsRemoved(ctx, t, testTableName, 0)
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
})
}
}
func TestPostgresConsumer_SeveralConsumers(t *testing.T) {
t.Parallel()
testTableName := "centrifugo_consumer_test_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testNotificationChannel := "centrifugo_test_channel_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testMethod := "method"
testPayload := []byte(`{"key":"value"}`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := setupTestTable(ctx, testTableName, testNotificationChannel)
require.NoError(t, err)
eventReceived := make(chan struct{})
consumerClosed := make(chan struct{})
config := PostgresConfig{
DSN: testPGDSN,
OutboxTableName: testTableName,
PartitionSelectLimit: 10,
NumPartitions: 1,
PartitionPollInterval: configtypes.Duration(300 * time.Millisecond),
PartitionNotificationChannel: testNotificationChannel,
}
var pool *pgxpool.Pool
numConsumers := 10
for i := 0; i < numConsumers; i++ {
consumer, err := NewPostgresConsumer(config, &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
require.Equal(t, testMethod, method)
require.Equal(t, testPayload, data)
close(eventReceived)
return nil
},
}, testPostgresCommon(testTableName))
require.NoError(t, err)
pool = consumer.pool
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
consumerClosed <- struct{}{}
}()
}
partition := 0
err = insertEvent(ctx, pool, testTableName, testMethod, testPayload, partition)
require.NoError(t, err)
waitCh(t, eventReceived, 30*time.Second, "timeout waiting for event")
ensureEventsRemoved(ctx, t, testTableName, partition)
cancel()
for i := 0; i < numConsumers; i++ {
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}
}
func TestPostgresConsumer_NotificationTrigger(t *testing.T) {
t.Parallel()
testTableName := "centrifugo_consumer_test_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testNotificationChannel := "centrifugo_test_channel_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testMethod := "method"
testPayload := []byte(`{"key":"value"}`)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := setupTestTable(ctx, testTableName, testNotificationChannel)
require.NoError(t, err)
eventsReceived := make(chan struct{})
consumerClosed := make(chan struct{})
config := PostgresConfig{
DSN: testPGDSN,
OutboxTableName: testTableName,
PartitionSelectLimit: 1,
NumPartitions: 1,
PartitionPollInterval: configtypes.Duration(300 * time.Hour), // Set a long poll interval
PartitionNotificationChannel: testNotificationChannel,
}
numEvents := 0
consumer, err := NewPostgresConsumer(config, &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
require.Equal(t, testMethod, method)
require.Equal(t, testPayload, data)
numEvents++
// Events keep being inserted, so the consumer may dispatch more events than
// the test reads. Respect ctx to not block forever inside the transaction.
select {
case eventsReceived <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
},
}, testPostgresCommon(testTableName))
require.NoError(t, err)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
partition := 0
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(200 * time.Millisecond):
err := insertEvent(ctx, consumer.pool, testTableName, testMethod, testPayload, partition)
if err != nil {
if ctx.Err() == nil {
t.Errorf("error inserting event: %v", err)
}
return
}
}
}
}()
waitCh(t, eventsReceived, 30*time.Second, "timeout waiting for event 1")
waitCh(t, eventsReceived, 30*time.Second, "timeout waiting for event 2")
waitCh(t, eventsReceived, 30*time.Second, "timeout waiting for event 3")
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}
func TestPostgresConsumer_DifferentPartitions(t *testing.T) {
t.Parallel()
testTableName := "centrifugo_consumer_test_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testNotificationChannel := "centrifugo_test_channel_" + strings.Replace(uuid.New().String(), "-", "_", -1)
testMethod := "method"
testPayload := []byte(`{"key":"value"}`)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := setupTestTable(ctx, testTableName, testNotificationChannel)
require.NoError(t, err)
eventsReceived := make(chan struct{})
consumerClosed := make(chan struct{})
config := PostgresConfig{
DSN: testPGDSN,
OutboxTableName: testTableName,
PartitionSelectLimit: 10,
NumPartitions: 2,
PartitionPollInterval: configtypes.Duration(100 * time.Millisecond),
PartitionNotificationChannel: testNotificationChannel,
}
numEvents := 0
var dispatchMu sync.Mutex
consumer, err := NewPostgresConsumer(config, &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
dispatchMu.Lock()
defer dispatchMu.Unlock()
require.Equal(t, testMethod, method)
require.Equal(t, testPayload, data)
numEvents++
eventsReceived <- struct{}{}
return nil
},
}, testPostgresCommon(testTableName))
require.NoError(t, err)
go func() {
err := consumer.Run(ctx)
require.ErrorIs(t, err, context.Canceled)
close(consumerClosed)
}()
err = insertEvent(ctx, consumer.pool, testTableName, testMethod, testPayload, 0)
require.NoError(t, err)
err = insertEvent(ctx, consumer.pool, testTableName, testMethod, testPayload, 1)
require.NoError(t, err)
waitCh(t, eventsReceived, 30*time.Second, "timeout waiting for event 1")
waitCh(t, eventsReceived, 30*time.Second, "timeout waiting for event 2")
ensureEventsRemoved(ctx, t, testTableName, 0)
ensureEventsRemoved(ctx, t, testTableName, 1)
cancel()
waitCh(t, consumerClosed, 30*time.Second, "timeout waiting for consumer closed")
}

View File

@@ -0,0 +1,238 @@
package consuming
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/centrifugal/centrifugo/v6/internal/metrics"
"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/redisqueue"
"github.com/centrifugal/centrifugo/v6/internal/redisshard"
"github.com/redis/rueidis"
)
type RedisStreamConsumerConfig = configtypes.RedisStreamConsumerConfig
// RedisStreamConsumer consumes messages from a Redis Stream.
type RedisStreamConsumer struct {
config RedisStreamConsumerConfig
dispatcher Dispatcher
consumers map[string]*redisqueue.Consumer
common *consumerCommon
}
// NewRedisStreamConsumer creates a new Redis Streams consumer.
func NewRedisStreamConsumer(
cfg RedisStreamConsumerConfig,
dispatcher Dispatcher,
common *consumerCommon,
) (*RedisStreamConsumer, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
shards, err := redisshard.BuildRedisShards(cfg.Redis)
if err != nil {
return nil, fmt.Errorf("failed to build Redis shards: %w", err)
}
if len(shards) != 1 {
return nil, fmt.Errorf("expected a single Redis shard, got %d", len(shards))
}
shard := shards[0]
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if shard.RunOp(func(client rueidis.Client) rueidis.RedisResult {
return client.Do(ctx, client.B().Ping().Build())
}).Error() != nil {
return nil, errors.New("failed to ping Redis")
}
consumer := &RedisStreamConsumer{
config: cfg,
dispatcher: dispatcher,
common: common,
}
consumers := make(map[string]*redisqueue.Consumer)
for _, stream := range cfg.Streams {
streamConsumer, err := redisqueue.NewConsumer(shard, redisqueue.ConsumerOptions{
Stream: stream,
ConsumerFunc: consumer.process,
Name: common.nodeID,
GroupName: cfg.ConsumerGroup,
VisibilityTimeout: cfg.VisibilityTimeout.ToDuration(),
BlockingTimeout: 5 * time.Second,
ReclaimInterval: 5 * time.Second,
Concurrency: cfg.NumWorkers,
UseLegacyReclaim: false,
})
if err != nil {
return nil, fmt.Errorf("failed to create consumer for stream %q: %w", stream, err)
}
consumers[stream] = streamConsumer
}
consumer.consumers = consumers
return consumer, nil
}
// process is the ConsumerFunc for redisqueue.Consumer.
func (c *RedisStreamConsumer) process(msg *redisqueue.Message) error {
if logging.Enabled(logging.DebugLevel) {
c.common.log.Debug().Str("stream", msg.ID).
Msg("received message from stream")
}
dataStr, ok := msg.Values[c.config.PayloadValue]
if !ok {
c.common.log.Error().
Str("expected_value", c.config.PayloadValue).
Msg("payload value not found in redis stream message")
return nil
}
ctx := context.Background()
var err error
if c.config.PublicationDataMode.Enabled {
err = c.processPublicationDataMessage(ctx, msg, []byte(dataStr))
} else {
err = c.processCommandMessage(ctx, msg, []byte(dataStr))
}
if err != nil {
metrics.ConsumerErrorsTotal.WithLabelValues(c.common.name).Inc()
c.common.log.Error().Err(err).Msg("error processing redis stream message")
} else {
metrics.ConsumerProcessedTotal.WithLabelValues(c.common.name).Inc()
}
return err
}
// Run starts the consumer loop by starting each underlying consumer in a separate goroutine.
// It also listens for context cancellation to gracefully shutdown all consumers.
func (c *RedisStreamConsumer) Run(ctx context.Context) error {
shutdownCh := make(chan struct{})
for stream, consumer := range c.consumers {
errCh := make(chan error, 64)
consumer.Errors = errCh
go func() {
for {
select {
case err := <-errCh:
if err != nil {
c.common.log.Error().Str("stream", stream).Err(err).Msg("error from consumer")
}
case <-shutdownCh:
return
}
}
}()
go consumer.Run()
}
go func() {
defer close(shutdownCh)
<-ctx.Done()
for _, consumer := range c.consumers {
consumer.Shutdown()
}
}()
<-ctx.Done()
<-shutdownCh
return ctx.Err()
}
// processPublicationDataMessage processes a message in publication data mode.
func (c *RedisStreamConsumer) processPublicationDataMessage(ctx context.Context, msg *redisqueue.Message, data []byte) error {
idempotencyKey := getStringProperty(msg, c.config.PublicationDataMode.IdempotencyKeyValue)
delta, err := getBoolProperty(msg, c.config.PublicationDataMode.DeltaValue)
if err != nil {
return fmt.Errorf("error parsing delta property %q: %w", c.config.PublicationDataMode.DeltaValue, err)
}
channelsStr := getStringProperty(msg, c.config.PublicationDataMode.ChannelsValue)
channels := strings.Split(channelsStr, ",")
if len(channels) == 0 || (len(channels) == 1 && channels[0] == "") {
c.common.log.Info().Msg("no channels found, skipping message")
return nil
}
tags := getTagsFromRedisValues(msg, c.config.PublicationDataMode.TagsValuePrefix)
version, err := getUint64Property(msg, c.config.PublicationDataMode.VersionValue)
if err != nil {
return fmt.Errorf("error parsing version property %q: %w", c.config.PublicationDataMode.VersionValue, err)
}
return c.dispatcher.DispatchPublication(ctx, channels, api.ConsumedPublication{
Data: data,
IdempotencyKey: idempotencyKey,
Delta: delta,
Tags: tags,
Version: version,
VersionEpoch: getStringProperty(msg, c.config.PublicationDataMode.VersionEpochValue),
})
}
// processCommandMessage processes a message in command mode.
func (c *RedisStreamConsumer) processCommandMessage(ctx context.Context, msg *redisqueue.Message, data []byte) error {
method := getStringProperty(msg, c.config.MethodValue)
return c.dispatcher.DispatchCommand(ctx, method, data)
}
// getStringProperty extracts a string property from the message values.
func getStringProperty(msg *redisqueue.Message, key string) string {
if key == "" {
return ""
}
return msg.Values[key]
}
func getUint64Property(msg *redisqueue.Message, key string) (uint64, error) {
if key == "" {
return 0, nil
}
val, ok := msg.Values[key]
if !ok {
return 0, nil
}
i, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return 0, fmt.Errorf("error parsing uint64 property %q: %w", key, err)
}
return i, nil
}
func getBoolProperty(msg *redisqueue.Message, key string) (bool, error) {
if key == "" {
return false, nil
}
val, ok := msg.Values[key]
if !ok {
return false, nil
}
b, err := strconv.ParseBool(val)
if err != nil {
return false, fmt.Errorf("error parsing bool property %q: %w", key, err)
}
return b, nil
}
// getTagsFromRedisValues extracts tag values from message values with the specified prefix.
func getTagsFromRedisValues(msg *redisqueue.Message, prefix string) map[string]string {
var tags map[string]string
if prefix == "" {
return tags
}
for k, v := range msg.Values {
if strings.HasPrefix(k, prefix) {
if tags == nil {
tags = make(map[string]string)
}
tags[strings.TrimPrefix(k, prefix)] = v
}
}
return tags
}

View File

@@ -0,0 +1,177 @@
//go:build integration
package consuming
import (
"bytes"
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
"github.com/centrifugal/centrifugo/v6/internal/redisqueue"
"github.com/centrifugal/centrifugo/v6/internal/redisshard"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
// TestRedisStreamConsumer tests the Redis Stream consumer which is used to consume
// two different streams.
func TestRedisStreamConsumer(t *testing.T) {
t.Parallel()
done1 := make(chan struct{})
done2 := make(chan struct{})
dispatcher := &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
if bytes.Equal(data, []byte("stream1")) {
close(done1)
} else if bytes.Equal(data, []byte("stream2")) {
close(done2)
}
return nil
},
}
streamName1 := "TEST_STREAM" + uuid.NewString()
streamName2 := "TEST_STREAM" + uuid.NewString()
cfg := RedisStreamConsumerConfig{
Redis: configtypes.Redis{
Address: []string{"localhost:6379"},
},
Streams: []string{streamName1, streamName2},
ConsumerGroup: uuid.NewString(),
PayloadValue: "payload",
NumWorkers: 1,
}
consumer, err := NewRedisStreamConsumer(
cfg, dispatcher, testCommon(prometheus.NewRegistry()))
if err != nil {
t.Fatalf("failed to create Redis Stream consumer: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
if err := consumer.Run(ctx); err != nil && err != context.Canceled {
t.Errorf("consumer error: %v", err)
}
}()
shards, err := redisshard.BuildRedisShards(cfg.Redis)
require.NoError(t, err)
require.Len(t, shards, 1)
producer, err := redisqueue.NewProducer(shards[0], redisqueue.ProducerOptions{
Stream: streamName1,
})
if err != nil {
t.Fatalf("failed to create producer: %v", err)
}
err = producer.Enqueue(&redisqueue.Message{
Values: map[string]string{
"payload": "stream1",
},
})
producer, err = redisqueue.NewProducer(shards[0], redisqueue.ProducerOptions{
Stream: streamName2,
})
if err != nil {
t.Fatalf("failed to create producer: %v", err)
}
err = producer.Enqueue(&redisqueue.Message{
Values: map[string]string{
"payload": "stream2",
},
})
waitCh(t, done1, 5*time.Second, "timeout waiting for message processing")
waitCh(t, done2, 5*time.Second, "timeout waiting for message processing")
}
// TestRedisStreamConsumer_ConcurrentConsumers tests that only one consumer processes
// the message when multiple consumers are running with the same consumer group.
func TestRedisStreamConsumer_ConcurrentConsumers(t *testing.T) {
t.Parallel()
done := make(chan string, 1)
var receivedNum atomic.Int64
dispatcher := func(name string) *MockDispatcher {
return &MockDispatcher{
onDispatchCommand: func(ctx context.Context, method string, data []byte) error {
receivedNum.Add(1)
close(done)
return nil
},
}
}
streamName := "TEST_STREAM_" + uuid.NewString()
consumerGroup := "group_" + uuid.NewString()
cfg := RedisStreamConsumerConfig{
Redis: configtypes.Redis{
Address: []string{"localhost:6379"},
},
Streams: []string{streamName},
ConsumerGroup: consumerGroup,
PayloadValue: "payload",
NumWorkers: 1,
}
// Build shards once to share producer
shards, err := redisshard.BuildRedisShards(configtypes.Redis{
Address: []string{"localhost:6379"},
})
require.NoError(t, err)
// Start first consumer
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
consumer1, err := NewRedisStreamConsumer(cfg, dispatcher("consumer1"), testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
if err := consumer1.Run(ctx1); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("consumer1 error: %v", err)
}
}()
// Start second consumer
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
consumer2, err := NewRedisStreamConsumer(cfg, dispatcher("consumer2"), testCommon(prometheus.NewRegistry()))
require.NoError(t, err)
go func() {
if err := consumer2.Run(ctx2); err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("consumer2 error: %v", err)
}
}()
// Send message.
producer, err := redisqueue.NewProducer(shards[0], redisqueue.ProducerOptions{
Stream: streamName,
})
require.NoError(t, err)
err = producer.Enqueue(&redisqueue.Message{
Values: map[string]string{
"payload": "test",
},
})
require.NoError(t, err)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for message processing")
}
time.Sleep(500 * time.Millisecond) // prevent any second dispatch.
require.Equal(t, int64(1), receivedNum.Load(), "only one consumer should process the message")
}