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

449 lines
14 KiB
Go

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
}