snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
448
internal/consuming/aws_sqs.go
Normal file
448
internal/consuming/aws_sqs.go
Normal 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
|
||||
}
|
||||
126
internal/consuming/aws_sqs_test.go
Normal file
126
internal/consuming/aws_sqs_test.go
Normal 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")
|
||||
}
|
||||
419
internal/consuming/azure_service_bus.go
Normal file
419
internal/consuming/azure_service_bus.go
Normal 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
|
||||
}
|
||||
21
internal/consuming/backoff.go
Normal file
21
internal/consuming/backoff.go
Normal 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)
|
||||
}
|
||||
79
internal/consuming/consuming.go
Normal file
79
internal/consuming/consuming.go
Normal 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
|
||||
}
|
||||
31
internal/consuming/consuming_test.go
Normal file
31
internal/consuming/consuming_test.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
240
internal/consuming/google_pub_sub.go
Normal file
240
internal/consuming/google_pub_sub.go
Normal 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
|
||||
}
|
||||
110
internal/consuming/google_pub_sub_test.go
Normal file
110
internal/consuming/google_pub_sub_test.go
Normal 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
934
internal/consuming/kafka.go
Normal 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
|
||||
}
|
||||
2038
internal/consuming/kafka_test.go
Normal file
2038
internal/consuming/kafka_test.go
Normal file
File diff suppressed because it is too large
Load Diff
403
internal/consuming/nats_jetstream.go
Normal file
403
internal/consuming/nats_jetstream.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
210
internal/consuming/nats_jetstream_test.go
Normal file
210
internal/consuming/nats_jetstream_test.go
Normal 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")
|
||||
}
|
||||
340
internal/consuming/postgresql.go
Normal file
340
internal/consuming/postgresql.go
Normal 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()
|
||||
}
|
||||
384
internal/consuming/postgresql_test.go
Normal file
384
internal/consuming/postgresql_test.go
Normal 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")
|
||||
}
|
||||
238
internal/consuming/redis_stream.go
Normal file
238
internal/consuming/redis_stream.go
Normal 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
|
||||
}
|
||||
177
internal/consuming/redis_stream_test.go
Normal file
177
internal/consuming/redis_stream_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user