fix: review findings (async SQS dispatcher, backoff, batch insert, shutdown order, pagination, HMAC) + loadtest fixes; v0.1.6

This commit is contained in:
“Naeel”
2026-08-16 18:24:09 +04:00
parent 090cedca67
commit 3fedf9317c
27 changed files with 581 additions and 122 deletions
+7 -3
View File
@@ -54,9 +54,13 @@ python3 -m loadtests.run soak --devices 50 --rate 1 --duration 3600 --slice-sec
## Ограничения
- API отдаёт максимум **1000 строк** телеметрии на запрос → верификатор
считает окно ~900 сообщений на устройство. Сценарии с большим expected
на устройство недосчитают (для точных потерь — дробить на прогоны).
- **SQS-лимит 256KB** на сообщение (shared-sqs отклоняет: `InvalidParameterValue
message size exceeds the limit`) → payload устройств ≤ ~250KB с учётом
envelope. Бридж дропает oversize с логом `payload too large for SQS`.
EMQX пропускает до 1MB, но узким местом конвейера является SQS.
- API отдаёт максимум **1000 строк** телеметрии на запрос; шлюз платформы
рвёт ответы >~15КБ (баг MSS/MTU, тикет Nubes) → верификатор ходит
страницами по 50 (`offset`). Большие страницы НЕ использовать.
- Задержка = `ts(PG) - sent_at - skew`; skew калибруется первым сообщением
(часы тест-машины и PG могут расходиться).
- Consumer long-poll до ~20с → после публикаций нужна пауза перед сверкой
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19 -9
View File
@@ -49,13 +49,23 @@ class API:
def _req(self, method, path, body=None):
url = self.base + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, data=data, method=method,
headers={"Authorization": "Bearer " + self.token,
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30, context=self.ctx) as r:
return json.loads(r.read().decode())
# Внешний путь платформы даёт таймауты ~31-33с (~5.5% запросов) —
# ретраи 3 с паузами (см. doc/sqs-integration.md, тикет Nubes).
last = None
for attempt in range(3):
try:
req = urllib.request.Request(
url, data=data, method=method,
headers={"Authorization": "Bearer " + self.token,
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=15, context=self.ctx) as r:
raw = r.read().decode()
return json.loads(raw) if raw.strip() else {}
except Exception as e:
last = e
time.sleep(1 + attempt)
raise last
def create_device(self, ns, name, device_id):
return self._req("POST", f"/v1/namespaces/{ns}/iot/devices",
@@ -70,8 +80,8 @@ class API:
def delete_device(self, ns, name):
return self._req("DELETE", f"/v1/namespaces/{ns}/iot/devices/{name}")
def telemetry(self, ns, device_id="", limit=1000):
q = f"?limit={limit}"
def telemetry(self, ns, device_id="", limit=1000, offset=0):
q = f"?limit={limit}&offset={offset}"
if device_id:
q += f"&device_id={device_id}"
return self._req("GET", f"/v1/namespaces/{ns}/iot/telemetry{q}")
+19 -15
View File
@@ -118,22 +118,21 @@ def burst(args):
duration=spike, qos=args.qos)
tail = _run_publishers(run_id + "-t", ns, devices,
rate=args.rate, duration=quiet, qos=args.qos)
expected_low = max(1, int(args.rate * quiet))
expected_high = max(1, int(args.rate * args.burst_mult * spike))
rep = Verifier(api, ns, run_id)
# считаем только фазу всплеска (низкие фазы — фон)
rep_high = rep.run_report(devices, expected_high)
# recovery: сколько времени после окончания всплеска очередь отдала всё
# Фазы различаются суффиксом run_id — верифицируем фазу "-h" отдельно.
high_sent = high.get("sent", 0)
expected_per_device = max(1, high_sent // max(1, len(devices)))
rep_high = Verifier(api, ns, run_id + "-h").run_report(
devices, expected_per_device)
# recovery: сколько времени после конца всплеска очередь отдала всё
_, recovery_s = _wait_delivery(
api, ns, run_id + "-h", devices, expected_high * len(devices),
timeout=300)
api, ns, run_id + "-h", devices, high_sent, timeout=300)
return {
"scenario": "burst",
"ns": ns,
"quiet_phase": low,
"spike_phase": high,
"tail_phase": tail,
"expected_high_total": expected_high * len(devices),
"sent_high_total": high_sent,
"recovery_sec": round(recovery_s, 1),
"high": rep_high,
}
@@ -143,6 +142,9 @@ def burst(args):
# --------------------------------------------------------------------------
# 3. LARGE_PAYLOAD — крупные сообщения
# Верификация через API невозможна: строка с большим payload в ответе >15 КБ,
# а шлюз платформы рвёт такие ответы (баг MSS/MTU, тикет Nubes). Доставку
# проверять по логам монолита: 'consumer: telemetry saved'.
# --------------------------------------------------------------------------
def large_payload(args):
run_id = f"big-{int(time.time())}"
@@ -150,15 +152,17 @@ def large_payload(args):
api = API()
devices = _create_devices(api, ns, run_id, min(args.devices, 10))
try:
expected = max(1, int(args.rate * args.duration))
counters = _run_publishers(
run_id, ns, devices, rate=args.rate, duration=args.duration,
qos=args.qos, payload_size=args.payload_size)
time.sleep(10)
rep = Verifier(api, ns, run_id).run_report(devices, expected)
rep.update({"scenario": "large_payload", "ns": ns,
"publisher": counters})
return rep
return {
"scenario": "large_payload",
"ns": ns,
"payload_size": args.payload_size,
"publisher": counters,
"note": "доставку смотреть в логах монолита (telemetry saved); "
"API-верификация невозможна из-за бага шлюза (>15KB ответ)",
}
finally:
_cleanup(api, ns, devices)
+15 -4
View File
@@ -28,11 +28,22 @@ class Verifier:
def device_report(self, device_id, expected):
"""Сверка по одному устройству. expected — сколько seq ждали.
Ограничение: API отдаёт максимум 1000 строк на устройство —
сценарии должны укладывать expected в ~900 на устройство."""
rows = self.api.telemetry(self.ns, device_id=device_id, limit=1000)
Пагинация по 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.get("items", []):
for r in rows:
p = r.get("payload") or {}
if p.get("run_id") != self.run_id:
continue