Files
IoT/internal/service/consumer/consumer.go
T
Repinoid 4031882cae feat(consumer): битые envelope → DLQ вместо потери
processTelemetry теперь возвращает sentinel errInvalidEnvelope при битом JSON
(раньше — молчаливый nil → mусор подтверждался и терялся). Consumer при этой
ошибке кладёт тело в DLQ (SQS_DLQ_NAME, дефолт iot-telemetry-dlq) и подтверждает
основное сообщение; без DLQ — только логирует, но НЕ подтверждает (оседает).
Добавлены SQSDLQName в config + DefaultSQSDLQName. Реальные ошибки PG (не invalid)
по-прежнему не подтверждаются и уходят в ретрай после visibility timeout.
2026-10-07 07:40:09 +03:00

158 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package consumer — чтение shared-SQS → per-tenant PostgreSQL.
//
// Роль старого sqs-consumer внутри монолита: long-poll цикл, обработка
// envelope телеметрии, DeleteMessage после успешной записи (at-least-once).
package consumer
import (
"context"
"errors"
"log/slog"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/config"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/sqsclient"
"gitea.services.ngcloud.ru/Nail/IoT/internal/storage/iotpg"
)
// errorBackoff — пауза между попытками при ошибках SQS.
const errorBackoff = 5 * time.Second
// maxProcessBackoff — потолок backoff после ошибок обработки (PG).
const maxProcessBackoff = 60 * time.Second
// Run — бесконечный long-poll цикл потребителя.
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *iotpg.IoTPostgresStore, log *slog.Logger) error {
queueURL, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSQueueName)
if err != nil {
return err
}
// DLQ присутствует не всегда: резолвим лениво, отказ не фатален (битые
// сообщения тогда просто НЕ подтверждаются и оседают после ретраев).
dlqURL, dlqErr := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSDLQName)
if dlqErr != nil {
log.Warn("consumer: DLQ не найдена, битые сообщения будут только логироваться", "dlq", cfg.SQSDLQName, "err", dlqErr)
dlqURL = ""
}
log.Info("consumer: SQS queue resolved", "queue", cfg.SQSQueueName, "dlq", cfg.SQSDLQName)
for {
if ctx.Err() != nil {
break
}
resp, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 10,
WaitTimeSeconds: int32(cfg.SQSLongPollSeconds),
VisibilityTimeout: int32(cfg.SQSVisibilityTimeout),
})
if err != nil {
if ctx.Err() != nil {
break
}
log.Error("consumer: ReceiveMessage failed", "err", err)
if !sleepCtx(ctx, errorBackoff) {
break
}
continue
}
processBackoff := time.Second
// Копим подтверждения, но НЕ удаляем из SQS сразу: InsertTelemetry лишь
// буферизует и возвращает nil до фактической записи. Удаляем только после
// успешного Flush — иначе краш/flush-ошибка = потеря подтверждённых
// сообщений (P0-7).
type pendingAck struct {
handle string
id string
}
var pending []pendingAck
for _, msg := range resp.Messages {
if msg.Body == nil || *msg.Body == "" {
log.Warn("consumer: empty message body", "message_id", aws.ToString(msg.MessageId))
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue
}
if err := processTelemetry(ctx, *msg.Body, store, log); err != nil {
// Битый envelope → DLQ (а не бесконечный ретрай и не потеря):
// кладём тело в DLQ, затем подтверждаем основное сообщение.
if errors.Is(err, errInvalidEnvelope) {
if dlqURL != "" {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, *msg.Body, log); derr != nil {
log.Error("consumer: send to DLQ failed", "err", derr, "message_id", aws.ToString(msg.MessageId))
}
} else {
log.Warn("consumer: invalid envelope dropped (DLQ отсутствует)", "message_id", aws.ToString(msg.MessageId))
}
// Подтверждаем: DLQ-отправка (или логирование) — терминальная обработка.
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue
}
log.Error("consumer: process telemetry", "err", err, "message_id", aws.ToString(msg.MessageId))
// Сообщение НЕ подтверждаем — вернётся после visibility timeout.
// Backoff с jitter — не долбить упавший PG в цикле
// (фикс HIGH из ревью 2026-08-16: лавина ретраев).
if !sleepCtx(ctx, processBackoff) {
break
}
processBackoff *= 2
if processBackoff > maxProcessBackoff {
processBackoff = maxProcessBackoff
}
continue
}
processBackoff = time.Second
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
}
if len(pending) == 0 {
continue
}
// Сначала гарантируем запись в БД, только потом удаляем из SQS.
if err := store.Flush(ctx); err != nil {
log.Error("consumer: flush failed — сообщения НЕ удалены, вернутся после visibility timeout", "err", err)
continue
}
for _, p := range pending {
if err := deleteMessage(ctx, sqsClient, queueURL, p.handle, log); err != nil {
log.Error("consumer: DeleteMessage failed", "err", err, "message_id", p.id)
}
}
}
log.Info("consumer: shutting down")
return nil
}
// deleteMessage удаляет обработанное сообщение из очереди.
func deleteMessage(ctx context.Context, client *sqs.Client, queueURL string, receiptHandle string, log *slog.Logger) error {
_, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: aws.String(receiptHandle),
})
return err
}
// sendToDLQ — кладёт битое неразборчивое сообщение в DLQ для диагностики.
func sendToDLQ(ctx context.Context, client *sqs.Client, dlqURL, body string, log *slog.Logger) error {
_, err := client.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(dlqURL),
MessageBody: aws.String(body),
})
return err
}
// sleepCtx — ожидание с прерыванием по ctx. false = ctx завершён.
func sleepCtx(ctx context.Context, d time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}