diff --git a/internal/storage/iotpg/iot_telemetry_store.go b/internal/storage/iotpg/iot_telemetry_store.go index 43e7a9d..36eac09 100644 --- a/internal/storage/iotpg/iot_telemetry_store.go +++ b/internal/storage/iotpg/iot_telemetry_store.go @@ -43,6 +43,11 @@ type IoTPostgresStore struct { // батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT) mu sync.Mutex + // flushMu сериализует фактическую запись (grab буфера + INSERT) между + // фоновым flusher'ом, Flush() из consumer и синхронной записью полного + // батча в InsertTelemetry. Без него Flush видел бы пустой буфер и возвращал + // nil, пока INSERT ещё в полёте → подтверждение SQS до записи (потеря). + flushMu sync.Mutex batches map[string][]telemetryInsert stopFlush chan struct{} flushWG sync.WaitGroup @@ -125,6 +130,12 @@ func (s *IoTPostgresStore) startFlusher() { // в буфер и будут повторены. Иначе фоновый flusher мог бы молча потерять // телеметрию, а consumer, увидев пустой буфер в Flush, подтвердил бы SQS-сообщение. func (s *IoTPostgresStore) flushAll(ctx context.Context) error { + // flushMu: см. поле. Сериализует запись целиком, чтобы Flush() из consumer + // дождался in-flight INSERT фонового flusher'а и вернул РЕАЛЬНУЮ ошибку + // (или nil только если всё записано), а не «буфер пуст, пока идёт запись». + s.flushMu.Lock() + defer s.flushMu.Unlock() + s.mu.Lock() if len(s.batches) == 0 { s.mu.Unlock() @@ -342,7 +353,11 @@ func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, devic s.mu.Unlock() if full { - if err := s.flushTenant(ctx, namespace, rows); err != nil { + // Синхронная запись полного батча — под тем же flushMu, что и фоновый + // flusher и Flush: чтобы не писать параллельно (см. поле flushMu). + s.flushMu.Lock() + err := s.flushTenant(ctx, namespace, rows) + if err != nil { if isRetryableFlushErr(err) { // Не теряем строки: возвращаем в буфер (сеть/PG временно лежит). s.mu.Lock() @@ -351,6 +366,9 @@ func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, devic } else { s.log.Error("iotpg: permanent error, batch dropped", "namespace", namespace, "rows", len(rows), "err", err) } + } + s.flushMu.Unlock() + if err != nil { return err } }