# verifier.py — проверка доставки: потери, дубли, задержка. # # Схема: паблишер кладёт в payload run_id + seq + sent_at (epoch ms). # Верификатор читает телеметрию через API и сверяет: # - delivered — сколько строк с run_id дошло; # - lost — сколько seq не доехало; # - duplicates — сколько seq приехало более одного раза; # - latency — ts(PG) - sent_at (epoch ms). Точность зависит от # синхронизации часов тест-машины и платформы (обе NTP). from datetime import datetime from .common import latency_stats def _ts_to_ms(ts): """'2026-08-16T14:23:22.363648+03:00' → epoch ms (UTC).""" try: return datetime.fromisoformat(ts).timestamp() * 1000 except (ValueError, TypeError): return None class Verifier: def __init__(self, api, ns, run_id): self.api = api self.ns = ns self.run_id = run_id def device_report(self, device_id, expected): """Сверка по одному устройству. expected — сколько seq ждали. Пагинация по 50 строк: шлюз платформы рвёт ответы >~15 КБ (баг MSS/MTU, тикет Nubes) — большие страницы НЕ использовать.""" rows = [] offset = 0 pages = 0 while pages < 40: page = self.api.telemetry(self.ns, device_id=device_id, limit=50, offset=offset) items = page.get("items", []) rows.extend(items) pages += 1 offset += 50 if len(items) < 50 or len(rows) >= max(expected + 100, 200): break seqs = {} for r in rows: p = r.get("payload") or {} if p.get("run_id") != self.run_id: continue s = p.get("seq") if s is None: continue seqs.setdefault(s, []).append( (r.get("ts"), p.get("sent_at")) ) delivered = len(seqs) lost = max(0, expected - delivered) duplicates = sum(len(v) - 1 for v in seqs.values() if len(v) > 1) lat = [] first_lat = None for s, entries in seqs.items(): ts, sent = entries[0] ts_ms = _ts_to_ms(ts) if ts else None if ts_ms is not None and isinstance(sent, (int, float)): l = max(0.0, ts_ms - sent) lat.append(l) if s == 0: first_lat = l return { "device_id": device_id, "expected": expected, "delivered": delivered, "lost": lost, "duplicates": duplicates, "lat_ms": lat, "latency": latency_stats(lat), "first_latency_ms": round(first_lat, 1) if first_lat is not None else None, } def run_report(self, devices, expected_per_device): """Сводка по всем устройствам. Возвращает dict метрик.""" total_expected = expected_per_device * len(devices) total_delivered = total_lost = total_dups = 0 per = [] all_lat = [] for d in devices: r = self.device_report(d["device_id"], expected_per_device) per.append(r) total_delivered += r["delivered"] total_lost += r["lost"] total_dups += r["duplicates"] all_lat += r["lat_ms"] return { "devices": len(devices), "expected": total_expected, "delivered": total_delivered, "lost": total_lost, "loss_rate": round(total_lost / total_expected, 6) if total_expected else 0.0, "duplicates": total_dups, "latency": latency_stats(all_lat), "per_device": per, }