fix(iot): построчный fallback при постоянной ошибке батча (🔴2)

Одна «ядовитая» строка (NULL/пустой payload, \u0000 в JSONB, битый JSON)
ронила весь multi-VALUES батч (до telemetryBatchSize валидных строк), который
классифицировался как permanent и выбрасывался целиком.

Добавлен flushRowsIndividually: при не-retryable ошибке батча вставляем строки
по одной (ON CONFLICT DO NOTHING) — валидные сохраняются, битая логируется.
This commit is contained in:
Repinoid
2026-10-10 18:53:34 +03:00
parent 7407de5fd3
commit e31f88ba6b
@@ -209,9 +209,40 @@ func (s *IoTPostgresStore) flushTenant(ctx context.Context, ns string, rows []te
}
sb.WriteString(" ON CONFLICT DO NOTHING")
_, err = tenantDB.ExecContext(ctx, sb.String(), args...)
if err != nil && !isRetryableFlushErr(err) {
// Построчный fallback: одна «ядовитая» строка (NULL/пустой payload,
// \u0000 в JSONB, битый JSON) роняет весь multi-VALUES батч (до
// telemetryBatchSize валидных строк). Вставляем по одной — валидные
// сохраняются, битая логируется, namespace не блокируется (ревью 🔴2).
s.flushRowsIndividually(ctx, tenantDB, ns, rows)
}
return err
}
// flushRowsIndividually — построчная вставка при ПОСТОЯННОЙ ошибке батча.
// Вызывается только из flushTenant, когда ошибка НЕ retryable (т.е. это данные,
// а не сеть/PG). Каждая строка вставляется отдельным INSERT ... ON CONFLICT DO
// NOTHING: валидные сохраняются, битая — логируется и пропускается (не роняет
// соседние и не блокирует namespace вечными ретраями).
func (s *IoTPostgresStore) flushRowsIndividually(ctx context.Context, tenantDB *sql.DB, ns string, rows []telemetryInsert) {
for _, r := range rows {
var mid any
if r.messageID != "" {
mid = r.messageID
}
ts := r.ts
if ts.IsZero() {
ts = time.Now().UTC()
}
_, err := tenantDB.ExecContext(ctx,
`INSERT INTO iot_telemetry (device_id, payload, message_id, ts) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
r.deviceID, r.payload, mid, ts)
if err != nil {
s.log.Error("iotpg: poison row dropped", "namespace", ns, "device", r.deviceID, "err", err)
}
}
}
// NewFromEnv создаёт store из env var IOT_PG_DSN.
// Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален.
func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) {