fix(telemetry): не терять строки при ошибке flush (flushAll + InsertTelemetry)

flushAll и InsertTelemetry (путь заполнения батча) вынимали строки из буфера и
подменяли буфер ДО записи: при ошибке flushTenant строки исчезали. Хуже —
фоновый flusher мог забрать строки, которые consumer ждал в Flush, тогда Flush
видел пустой буфер, возвращал nil, и consumer подтверждал SQS-сообщение с
незаписанными данными (потеря). Теперь при ошибке строки возвращаются в буфер
в обоих путях. ctx проброшен в flushAll/flushTenant. Consumer хранит receipt
handle значением (string), не указателем SDK.
This commit is contained in:
Repinoid
2026-10-07 07:32:00 +03:00
parent 38602b7560
commit b6c083e80f
3 changed files with 42 additions and 14 deletions
+13
View File
@@ -61,6 +61,19 @@
не вставляется. не вставляется.
- Легаси `cmd/sqs-consumer`: вызов обновлён (`""`), файл помечен к удалению (P2-14). - Легаси `cmd/sqs-consumer`: вызов обновлён (`""`), файл помечен к удалению (P2-14).
### Ревью-фикс P0-7/P0-8 (найдено при перепроверке кода)
- ❗ Была реальная потеря данных: `flushAll` и `InsertTelemetry` (при заполнении
батча) вынимали строки из буфера и **подменяли буфер ДО записи**; при ошибке
`flushTenant` строки исчезали. Хуже — фоновый flusher мог «съесть» строки,
которые consumer ждал в `Flush`, тогда `Flush` вернул бы nil (буфер пуст) и
consumer **подтвердил бы** SQS-сообщение с незаписанными данными.
- Фикс: при ошибке `flushTenant` строки ВОЗВРАЩАЮТСЯ в буфер (в обоих путях) —
данные не теряются, повтор выполняет flusher/Flush. `ctx` проброшен в
`flushAll`/`flushTenant` (раньше `Flush(ctx)` игнорировал ctx).
- Consumer: receipt handle хранится значением (`string`), а не указателем SDK.
- Компромисс: при долгом сбое PG буфер растёт (backpressure) — осознанно, вместо
тихой потери телеметрии.
## Осталось (не сделано) ## Осталось (не сделано)
- P0-3 часть 1 (хэш паролей + EMQX), P1-11/P1-12 (тесты/CI), P2-14 - P0-3 часть 1 (хэш паролей + EMQX), P1-11/P1-12 (тесты/CI), P2-14
(вычистить легаси `internal/api/`, `cmd/sqs-consumer`). (вычистить легаси `internal/api/`, `cmd/sqs-consumer`).
+5 -5
View File
@@ -59,14 +59,14 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
// успешного Flush — иначе краш/flush-ошибка = потеря подтверждённых // успешного Flush — иначе краш/flush-ошибка = потеря подтверждённых
// сообщений (P0-7). // сообщений (P0-7).
type pendingAck struct { type pendingAck struct {
handle *string handle string
id string id string
} }
var pending []pendingAck var pending []pendingAck
for _, msg := range resp.Messages { for _, msg := range resp.Messages {
if msg.Body == nil || *msg.Body == "" { if msg.Body == nil || *msg.Body == "" {
log.Warn("consumer: empty message body", "message_id", aws.ToString(msg.MessageId)) log.Warn("consumer: empty message body", "message_id", aws.ToString(msg.MessageId))
pending = append(pending, pendingAck{msg.ReceiptHandle, aws.ToString(msg.MessageId)}) pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
continue continue
} }
if err := processTelemetry(ctx, *msg.Body, store, log); err != nil { if err := processTelemetry(ctx, *msg.Body, store, log); err != nil {
@@ -84,7 +84,7 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
continue continue
} }
processBackoff = time.Second processBackoff = time.Second
pending = append(pending, pendingAck{msg.ReceiptHandle, aws.ToString(msg.MessageId)}) pending = append(pending, pendingAck{aws.ToString(msg.ReceiptHandle), aws.ToString(msg.MessageId)})
} }
if len(pending) == 0 { if len(pending) == 0 {
@@ -107,10 +107,10 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
} }
// deleteMessage удаляет обработанное сообщение из очереди. // deleteMessage удаляет обработанное сообщение из очереди.
func deleteMessage(ctx context.Context, client *sqs.Client, queueURL string, receiptHandle *string, log *slog.Logger) error { func deleteMessage(ctx context.Context, client *sqs.Client, queueURL string, receiptHandle string, log *slog.Logger) error {
_, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{ _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL), QueueUrl: aws.String(queueURL),
ReceiptHandle: receiptHandle, ReceiptHandle: aws.String(receiptHandle),
}) })
return err return err
} }
+24 -9
View File
@@ -113,14 +113,17 @@ func (s *IoTPostgresStore) startFlusher() {
case <-s.stopFlush: case <-s.stopFlush:
return return
case <-t.C: case <-t.C:
s.flushAll(false) s.flushAll(context.Background())
} }
} }
}() }()
} }
// flushAll — flush всех накопленных батчей. reportErr=false: только лог. // flushAll — flush всех накопленных батчей под переданным ctx.
func (s *IoTPostgresStore) flushAll(reportErr bool) error { // ВАЖНО: при ошибке записи тенанта строки НЕ теряются — возвращаются обратно
// в буфер и будут повторены. Иначе фоновый flusher мог бы молча потерять
// телеметрию, а consumer, увидев пустой буфер в Flush, подтвердил бы SQS-сообщение.
func (s *IoTPostgresStore) flushAll(ctx context.Context) error {
s.mu.Lock() s.mu.Lock()
if len(s.batches) == 0 { if len(s.batches) == 0 {
s.mu.Unlock() s.mu.Unlock()
@@ -132,11 +135,15 @@ func (s *IoTPostgresStore) flushAll(reportErr bool) error {
var firstErr error var firstErr error
for ns, rows := range batches { for ns, rows := range batches {
if err := s.flushTenant(ns, rows); err != nil { if err := s.flushTenant(ctx, ns, rows); err != nil {
s.log.Error("iotpg: flush batch", "namespace", ns, "rows", len(rows), "err", err) s.log.Error("iotpg: flush batch", "namespace", ns, "rows", len(rows), "err", err)
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
// Возвращаем строки в буфер — не теряем данные, повторим позже.
s.mu.Lock()
s.batches[ns] = append(s.batches[ns], rows...)
s.mu.Unlock()
} }
} }
return firstErr return firstErr
@@ -147,12 +154,12 @@ func (s *IoTPostgresStore) flushAll(reportErr bool) error {
// ТОЛЬКО фактически записанные данные: без этого краш/flush-ошибка после // ТОЛЬКО фактически записанные данные: без этого краш/flush-ошибка после
// InsertTelemetry (которая лишь буферизует и возвращает nil) = потеря сообщений. // InsertTelemetry (которая лишь буферизует и возвращает nil) = потеря сообщений.
func (s *IoTPostgresStore) Flush(ctx context.Context) error { func (s *IoTPostgresStore) Flush(ctx context.Context) error {
return s.flushAll(true) return s.flushAll(ctx)
} }
// flushTenant — INSERT батча строк одного тенанта (multi-VALUES). // flushTenant — INSERT батча строк одного тенанта (multi-VALUES).
func (s *IoTPostgresStore) flushTenant(ns string, rows []telemetryInsert) error { func (s *IoTPostgresStore) flushTenant(ctx context.Context, ns string, rows []telemetryInsert) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
tenantDB, err := s.getTenantDB(ctx, ns) tenantDB, err := s.getTenantDB(ctx, ns)
if err != nil { if err != nil {
@@ -320,7 +327,15 @@ func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, devic
s.mu.Unlock() s.mu.Unlock()
if full { if full {
return s.flushTenant(namespace, rows) if err := s.flushTenant(ctx, namespace, rows); err != nil {
// Не теряем строки: возвращаем в буфер. Иначе ранее забуференные
// сообщения, которые consumer собирался подтвердить через Flush,
// оказались бы записаны «мимо» и потеряны.
s.mu.Lock()
s.batches[namespace] = append(s.batches[namespace], rows...)
s.mu.Unlock()
return err
}
} }
return nil return nil
} }
@@ -488,7 +503,7 @@ func isDBNotExistErr(err error) bool {
func (s *IoTPostgresStore) Close() error { func (s *IoTPostgresStore) Close() error {
close(s.stopFlush) close(s.stopFlush)
s.flushWG.Wait() s.flushWG.Wait()
_ = s.flushAll(true) _ = s.flushAll(context.Background())
s.tenants.Range(func(_, value any) bool { s.tenants.Range(func(_, value any) bool {
if db, ok := value.(*sql.DB); ok { if db, ok := value.(*sql.DB); ok {
db.Close() db.Close()