Files
IoT/internal/service/consumer/consumer.go
T
Repinoid ab146861d3 fix(iot): не терять битые сообщения при недоступной DLQ (🔴7)
- ResolveQueueURL для DLQ теперь ретраится 3 раза с backoff: временная ошибка
  больше не отключает DLQ навсегда.
- Битый envelope подтверждается только если DLQ реально приняла его; при
  недоступной DLQ/сбое sendToDLQ сообщение НЕ подтверждается (вернётся после
  visibility timeout), а не дропается.
2026-10-10 18:58:26 +03:00

174 lines
7.2 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 резолвим с ретраями: временная ошибка ResolveQueueURL НЕ должна навсегда
// отключить DLQ — иначе битые сообщения подтверждались бы и терялись без следа
// (ревью 2026-10-10, 🔴7). При окончательном провале dlqURL остаётся "" —
// битые сообщения тогда НЕ подтверждаются (см. обработку errInvalidEnvelope).
dlqURL := ""
for attempt := 1; attempt <= 3; attempt++ {
u, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSDLQName)
if err == nil {
dlqURL = u
break
}
log.Warn("consumer: DLQ resolve failed", "dlq", cfg.SQSDLQName, "attempt", attempt, "err", err)
if !sleepCtx(ctx, errorBackoff) {
break
}
}
if dlqURL == "" {
log.Warn("consumer: DLQ недоступна — битые сообщения НЕ будут подтверждаться (останутся в очереди)", "dlq", cfg.SQSDLQName)
}
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) {
// Подтверждаем ТОЛЬКО если DLQ реально приняла сообщение: иначе
// битый теряется без следа (ревью 🔴7). При недоступной DLQ или
// сбое sendToDLQ НЕ подтверждаем — сообщение вернётся после
// visibility timeout (at-least-once).
if dlqURL != "" {
if derr := sendToDLQ(ctx, sqsClient, dlqURL, *msg.Body, log); derr == nil {
pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
} else {
log.Error("consumer: send to DLQ failed — сообщение НЕ подтверждено, вернётся после visibility timeout", "err", derr, "message_id", aws.ToString(msg.MessageId))
}
} else {
log.Warn("consumer: DLQ недоступна — битое сообщение НЕ подтверждено, вернётся после visibility timeout", "message_id", 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
}
}