103 lines
4.0 KiB
Python
103 lines
4.0 KiB
Python
# 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,
|
|
}
|