test(loadtests): 10 load scenarios + verifier; docs: Sonnet review findings

This commit is contained in:
“Naeel”
2026-08-16 16:59:29 +04:00
parent a4a11bae54
commit 090cedca67
14 changed files with 1024 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
# publisher.py — MQTT-паблишер: один поток на устройство.
#
# Каждый DevicePublisher:
# - подключается по wss (subprotocol mqtt), проверяет CONNACK через on_connect;
# - публикует в <ns>/telemetry/<device_id> с заданной частотой;
# - нумерует сообщения (seq) и кладёт sent_at (epoch ms) — для верификатора;
# - умеет периодически рвать соединение (reconnect_every) — эмуляция
# edge-разрывов платформы (~150с) и reconnect-storm;
# - ведёт метрики: sent, conn_ok, conn_fail, disconnects, connack_rc{...}.
import json
import ssl
import threading
import time
import paho.mqtt.client as mqtt
from .common import Counter, WS_HOST, WS_PATH, WS_PORT, now_ms, ws_tls_opts
class DevicePublisher(threading.Thread):
def __init__(self, run_id, ns, device_id, username, password,
rate=1.0, duration=60.0, qos=0, payload_size=128,
reconnect_every=0, counter=None, extra_topic=None,
publish_foreign=False):
super().__init__(daemon=True)
self.run_id = run_id
self.ns = ns
self.device_id = device_id
self.username = username
self.password = password
self.rate = rate # сообщений/сек
self.duration = duration # секунд
self.qos = qos
self.payload_size = payload_size
self.reconnect_every = reconnect_every # 0 = не рвать
self.counter = counter or Counter()
self.extra_topic = extra_topic # дополнительный (чужой) топик
self.publish_foreign = publish_foreign # публиковать в чужой топик
self.connack_rc = None
self.disconnected = threading.Event()
self.stop = threading.Event()
self.topic = f"{ns}/telemetry/{device_id}"
self._connack = threading.Event()
# --- MQTT ---
def _on_connect(self, client, userdata, flags, rc, props=None):
self.connack_rc = rc
self.counter.inc(f"connack_rc{rc}")
if rc == 0:
self.counter.inc("conn_ok")
else:
self.counter.inc("conn_fail")
self._connack.set()
def _on_disconnect(self, client, userdata, rc, props=None):
self.counter.inc("disconnects")
self.disconnected.set()
def _client(self):
c = mqtt.Client(client_id=f"lt-{self.run_id}-{self.device_id}",
transport="websockets")
c.ws_set_options(path=WS_PATH)
c.username_pw_set(self.username, self.password)
ws_tls_opts(c)
c.on_connect = self._on_connect
c.on_disconnect = self._on_disconnect
c.reconnect_delay_set(min_delay=2, max_delay=10)
return c
def _payload(self, seq):
pad = "x" * max(0, self.payload_size)
return json.dumps({
"run_id": self.run_id,
"seq": seq,
"sent_at": now_ms(),
"device_id": self.device_id,
"pad": pad,
})
def _connect(self, client, timeout=20):
self._connack.clear()
client.connect(WS_HOST, WS_PORT, timeout)
client.loop_start()
return self._connack.wait(timeout)
# --- поток ---
def run(self):
client = self._client()
seq = 0
t0 = time.monotonic()
next_t = t0
interval = 1.0 / self.rate if self.rate > 0 else 1.0
connected = False
last_reconnect = time.monotonic()
while not self.stop.is_set() and (time.monotonic() - t0) < self.duration:
if not connected:
if not self._connect(client):
self.counter.inc("conn_fail")
time.sleep(2)
continue
connected = True
# периодический разрыв — эмуляция edge-разрывов платформы
if self.reconnect_every and \
time.monotonic() - last_reconnect >= self.reconnect_every:
self.counter.inc("forced_reconnects")
client.disconnect()
client.loop_stop()
client = self._client()
connected = False
last_reconnect = time.monotonic()
continue
now = time.monotonic()
if now < next_t:
time.sleep(min(next_t - now, 0.5))
continue
next_t += interval
if next_t < now: # отстали — не навёрстываем очередью
next_t = now + interval
payload = self._payload(seq)
r = client.publish(self.topic, payload, qos=self.qos)
if r.rc == mqtt.MQTT_ERR_SUCCESS:
self.counter.inc("sent")
seq += 1
else:
self.counter.inc("publish_errors")
# потеряли соединение — переподключимся
connected = False
if self.publish_foreign and self.extra_topic:
# ACL-тест: публикация в чужой топик должна разорвать сессию
self.counter.inc("foreign_publish")
client.publish(self.extra_topic, payload, qos=0)
if self.disconnected.wait(3):
self.counter.inc("foreign_denied")
else:
self.counter.inc("foreign_accepted")
self.disconnected.clear()
try:
client.disconnect()
client.loop_stop()
except Exception:
pass