package sqs import ( "context" "errors" "net/url" "strconv" "strings" "time" awsv2 "github.com/aws/aws-sdk-go-v2/aws" awsconfig "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" sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" appconfig "wucher/internal/config" "wucher/internal/shared/pkg/metrics" "wucher/internal/queue" ) type API interface { SendMessage(ctx context.Context, params *sqs.SendMessageInput, optFns ...func(*sqs.Options)) (*sqs.SendMessageOutput, error) ReceiveMessage(ctx context.Context, params *sqs.ReceiveMessageInput, optFns ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) DeleteMessageBatch(ctx context.Context, params *sqs.DeleteMessageBatchInput, optFns ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) ChangeMessageVisibility(ctx context.Context, params *sqs.ChangeMessageVisibilityInput, optFns ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error) GetQueueAttributes(ctx context.Context, params *sqs.GetQueueAttributesInput, optFns ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) } type EmailQueue struct { api API queueURL string queueName string component string queueType string messageGroupID string maxMessages int waitTimeSeconds int32 visibilityTimeout int32 } type QueueOption func(*EmailQueue) func WithMetricsComponent(component string) QueueOption { return func(q *EmailQueue) { if q == nil { return } component = strings.TrimSpace(component) if component == "" { return } q.component = component } } func NewClient(ctx context.Context, cfg appconfig.SQSConfig) (*sqs.Client, error) { if err := validateConfig(cfg); err != nil { return nil, err } loadOptions := make([]func(*awsconfig.LoadOptions) error, 0, 1) if strings.TrimSpace(cfg.Region) != "" { loadOptions = append(loadOptions, awsconfig.WithRegion(strings.TrimSpace(cfg.Region))) } if key := strings.TrimSpace(cfg.AccessKeyID); key != "" { secret := strings.TrimSpace(cfg.SecretAccessKey) loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(key, secret, ""))) } awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...) if err != nil { return nil, err } client := sqs.NewFromConfig(awsCfg, func(options *sqs.Options) { if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" { options.EndpointResolver = sqs.EndpointResolverFromURL(endpoint) } }) return client, nil } func NewEmailQueue(api API, cfg appconfig.SQSConfig, opts ...QueueOption) (*EmailQueue, error) { return NewQueue(api, cfg, opts...) } func NewQueue(api API, cfg appconfig.SQSConfig, opts ...QueueOption) (*EmailQueue, error) { if err := validateConfig(cfg); err != nil { return nil, err } if api == nil { return nil, errors.New("sqs client is required") } waitTimeSeconds := cfg.WaitTimeSeconds if waitTimeSeconds <= 0 { waitTimeSeconds = 20 } if waitTimeSeconds > 20 { waitTimeSeconds = 20 } maxMessages := cfg.MaxMessages if maxMessages <= 0 { maxMessages = 10 } if maxMessages > 10 { maxMessages = 10 } visibilityTimeout := int32(cfg.VisibilityTimeout.Seconds()) if visibilityTimeout <= 0 { visibilityTimeout = 60 } if visibilityTimeout > 43200 { visibilityTimeout = 43200 } queueType := strings.ToLower(strings.TrimSpace(cfg.QueueType)) if queueType == "" { queueType = queue.QueueTypeStandard } queue := &EmailQueue{ api: api, queueURL: strings.TrimSpace(cfg.QueueURL), queueName: metrics.QueueNameFromURL(cfg.QueueURL), component: "worker", queueType: queueType, messageGroupID: strings.TrimSpace(cfg.MessageGroupID), maxMessages: maxMessages, waitTimeSeconds: waitTimeSeconds, visibilityTimeout: visibilityTimeout, } for _, opt := range opts { if opt != nil { opt(queue) } } metrics.InitializeQueueSeries(queue.component, queue.queueName) return queue, nil } func (q *EmailQueue) Backend() string { return queue.BackendSQS } func (q *EmailQueue) Publish(ctx context.Context, message queue.OutboundMessage) error { input := &sqs.SendMessageInput{ QueueUrl: awsv2.String(q.queueURL), MessageBody: awsv2.String(string(message.Body)), } if len(message.Attributes) > 0 { input.MessageAttributes = make(map[string]sqstypes.MessageAttributeValue, len(message.Attributes)) for key, value := range message.Attributes { v := value input.MessageAttributes[key] = sqstypes.MessageAttributeValue{ DataType: awsv2.String("String"), StringValue: awsv2.String(v), } } } if q.queueType == queue.QueueTypeFIFO { groupID := strings.TrimSpace(message.MessageGroupID) if groupID == "" { groupID = q.messageGroupID } if groupID == "" { groupID = "default" } deduplicationID := strings.TrimSpace(message.DeduplicationID) if deduplicationID == "" { deduplicationID = message.ID } input.MessageGroupId = awsv2.String(groupID) input.MessageDeduplicationId = awsv2.String(deduplicationID) } started := time.Now() _, err := q.api.SendMessage(ctx, input) metrics.ObserveQueueSend(q.component, q.queueName, int64(len(message.Body)), err, started) return err } func (q *EmailQueue) Receive(ctx context.Context, maxMessages int) ([]*queue.Delivery, error) { if maxMessages <= 0 { maxMessages = 1 } if q.maxMessages > 0 && maxMessages > q.maxMessages { maxMessages = q.maxMessages } if maxMessages > 10 { maxMessages = 10 } started := time.Now() output, err := q.api.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: awsv2.String(q.queueURL), MaxNumberOfMessages: int32(maxMessages), WaitTimeSeconds: q.waitTimeSeconds, VisibilityTimeout: q.visibilityTimeout, MessageSystemAttributeNames: []sqstypes.MessageSystemAttributeName{ sqstypes.MessageSystemAttributeNameApproximateReceiveCount, sqstypes.MessageSystemAttributeNameSentTimestamp, }, MessageAttributeNames: []string{"All"}, }) if err != nil { metrics.ObserveQueueReceive(q.component, q.queueName, 0, 0, err, started) return nil, err } var totalMessageBytes int64 for i := range output.Messages { totalMessageBytes += int64(len(awsv2.ToString(output.Messages[i].Body))) } metrics.ObserveQueueReceive(q.component, q.queueName, len(output.Messages), totalMessageBytes, nil, started) deliveries := make([]*queue.Delivery, 0, len(output.Messages)) for _, message := range output.Messages { attributes := make(map[string]string, len(message.MessageAttributes)) for key, value := range message.MessageAttributes { if value.StringValue != nil { attributes[key] = *value.StringValue } } receiveCount, _ := strconv.Atoi(message.Attributes[string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount)]) sentAt := time.Now().UTC() if raw := message.Attributes[string(sqstypes.MessageSystemAttributeNameSentTimestamp)]; raw != "" { if millis, convErr := strconv.ParseInt(raw, 10, 64); convErr == nil { sentAt = time.UnixMilli(millis).UTC() } } body := awsv2.ToString(message.Body) deliveries = append(deliveries, &queue.Delivery{ ID: awsv2.ToString(message.MessageId), ReceiptHandle: awsv2.ToString(message.ReceiptHandle), Body: []byte(body), Attributes: attributes, ReceiveCount: receiveCount, EnqueuedAt: sentAt, }) } return deliveries, nil } func (q *EmailQueue) Ack(ctx context.Context, deliveries []*queue.Delivery) error { if len(deliveries) == 0 { return nil } entries := make([]sqstypes.DeleteMessageBatchRequestEntry, 0, len(deliveries)) for i, delivery := range deliveries { if delivery == nil || strings.TrimSpace(delivery.ReceiptHandle) == "" { continue } entries = append(entries, sqstypes.DeleteMessageBatchRequestEntry{ Id: awsv2.String(strconv.Itoa(i)), ReceiptHandle: awsv2.String(delivery.ReceiptHandle), }) } if len(entries) == 0 { return nil } started := time.Now() output, err := q.api.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{ QueueUrl: awsv2.String(q.queueURL), Entries: entries, }) if err != nil { metrics.ObserveQueueDeleteBatch(q.component, q.queueName, 0, err, started) return err } if len(output.Failed) > 0 { batchErr := errors.New("sqs batch delete reported failures") metrics.ObserveQueueDeleteBatch(q.component, q.queueName, len(output.Successful), batchErr, started) return batchErr } metrics.ObserveQueueDeleteBatch(q.component, q.queueName, len(output.Successful), nil, started) return nil } func (q *EmailQueue) Retry(ctx context.Context, delivery *queue.Delivery, delay time.Duration) error { if delivery == nil { return nil } seconds := int32(delay.Seconds()) if seconds < 0 { seconds = 0 } if seconds > 43200 { seconds = 43200 } started := time.Now() _, err := q.api.ChangeMessageVisibility(ctx, &sqs.ChangeMessageVisibilityInput{ QueueUrl: awsv2.String(q.queueURL), ReceiptHandle: awsv2.String(delivery.ReceiptHandle), VisibilityTimeout: seconds, }) metrics.ObserveQueueVisibilityChange(q.component, q.queueName, err, started) return err } func (q *EmailQueue) DeadLetter(ctx context.Context, delivery *queue.Delivery, reason string, cause error) error { // Leave the message unacked so SQS redrive policy can move it to the queue DLQ. _ = ctx _ = delivery _ = reason _ = cause return nil } func (q *EmailQueue) ApproximateDepth(ctx context.Context) (int64, error) { started := time.Now() output, err := q.api.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{ QueueUrl: awsv2.String(q.queueURL), AttributeNames: []sqstypes.QueueAttributeName{ sqstypes.QueueAttributeNameApproximateNumberOfMessages, sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible, }, }) if err != nil { metrics.ObserveQueueDepth(q.component, q.queueName, 0, err, started) return 0, err } visible, _ := strconv.ParseInt(output.Attributes[string(sqstypes.QueueAttributeNameApproximateNumberOfMessages)], 10, 64) inFlight, _ := strconv.ParseInt(output.Attributes[string(sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible)], 10, 64) depth := visible + inFlight metrics.ObserveQueueDepth(q.component, q.queueName, depth, nil, started) return depth, nil } func (q *EmailQueue) Close() error { return nil } func validateConfig(cfg appconfig.SQSConfig) error { if strings.TrimSpace(cfg.QueueURL) == "" { return errors.New("sqs queue url is required") } if strings.TrimSpace(cfg.Region) == "" { return errors.New("aws region is required") } if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" { parsed, err := url.Parse(endpoint) if err != nil { return err } if parsed.Scheme != "https" && !cfg.AllowInsecureEndpoint { return errors.New("insecure sqs endpoint is disabled") } } queueType := strings.ToLower(strings.TrimSpace(cfg.QueueType)) if queueType == "" { queueType = queue.QueueTypeStandard } switch queueType { case queue.QueueTypeStandard: case queue.QueueTypeFIFO: if strings.TrimSpace(cfg.MessageGroupID) == "" { return errors.New("sqs message group id is required for fifo queues") } default: return errors.New("unsupported sqs queue type") } return nil }