chore: нагрузочный замер v2, push образов в реестр (#86, #85)

- loadtest: у каждого запроса своя сессия, режим --mode v2 (задача + опрос),
  X-API-Key; замер v1/v2 в docs/Deployment.md.
- make push ver=... — образы robotex-api и robotex-worker в docker.sokolab.xyz;
  prod-compose и make release ссылаются на образы реестра.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 22:02:59 +04:00
1 parent 1a4bd8d672
commit 11ba83140d
4 files changed
+86 -23

No files matched your search

+42 -11
View File
@@ -1,8 +1,9 @@
"""Нагрузочный тест /v1/solve: N параллельных запросов на каждом уровне + пиковое потребление
контейнера по `docker stats`.
"""Нагрузочный тест: N параллельных запросов на каждом уровне (каждый — в своей сессии) + пиковое
потребление контейнера воркера по `docker stats`.
docker compose --profile loadtest up -d
python tests/loadtest.py --levels 1 5 10 15
python tests/loadtest.py --api-key rbx_... --levels 1 5 10 15 # /v1/solve
python tests/loadtest.py --api-key rbx_... --mode v2 --levels 4 8 # /v2/tasks + опрос
Помимо status ответа, неуспех раскладывается по причинам: нет слотов, застряли на Cloudflare,
упало действие, исключение до действий.
@@ -82,12 +83,43 @@ class StatsSampler(threading.Thread):
self.stop_event.wait(1)
async def one(client: httpx.AsyncClient, api: str, payload: dict) -> tuple[str, float]:
class TaskResult:
"""Ответ /v2 в виде, понятном classify(): status_code и json()."""
def __init__(self, status_code: int, data: dict):
self.status_code, self._data = status_code, data
def json(self) -> dict:
return self._data
async def solve_v2(client: httpx.AsyncClient, api: str, payload: dict):
created = await client.post(f"{api}/v2/tasks", json=payload)
if created.status_code != 202:
return created
url = f"{api}{created.json()['url']}"
while True:
await asyncio.sleep(0.5)
task = (await client.get(url)).json()
if task["status"] == "done":
return TaskResult(200, task["result"])
if task["status"] not in ("queued", "running"):
return TaskResult(200, {"status": "err", "code": task["status"], "error": task.get("error")})
async def one(args, payload: dict) -> tuple[str, float]:
# у каждого запроса своя сессия: общий клиент запомнил бы cookie session_id, и задачи одной
# сессии выполнялись бы по очереди
headers = {"X-API-Key": args.api_key} if args.api_key else {}
t0 = time.monotonic()
try:
resp = await client.post(f"{api}/v1/solve", json=payload)
except Exception as e:
resp = e
async with httpx.AsyncClient(timeout=args.http_timeout, trust_env=False, headers=headers) as client:
try:
if args.mode == "v2":
resp = await solve_v2(client, args.api, payload)
else:
resp = await client.post(f"{args.api}/v1/solve", json=payload)
except Exception as e:
resp = e
return classify(resp), time.monotonic() - t0
@@ -95,9 +127,7 @@ async def run_level(n: int, args) -> None:
sampler = StatsSampler(args.container)
sampler.start()
t0 = time.monotonic()
headers = {"X-API-Key": args.api_key} if args.api_key else {}
async with httpx.AsyncClient(timeout=args.http_timeout, trust_env=False, headers=headers) as client:
results = await asyncio.gather(*(one(client, args.api, build_payload(args)) for _ in range(n)))
results = await asyncio.gather(*(one(args, build_payload(args)) for _ in range(n)))
wall = time.monotonic() - t0
sampler.stop_event.set()
sampler.join()
@@ -122,6 +152,7 @@ async def main():
p.add_argument("--selector", default='a[href*="/project/"]')
p.add_argument("--proxy", default="http://proxy:8080")
p.add_argument("--captcha-type", default="cloudflare")
p.add_argument("--mode", choices=["v1", "v2"], default="v1", help="v1 — /v1/solve, v2 — /v2/tasks с опросом")
p.add_argument("--api-key", default=os.getenv("ROBOTEX_API_KEY"), help="X-API-Key (или ROBOTEX_API_KEY)")
# Chrome работает в воркере — его docker stats и снимаем
p.add_argument("--container", default="robotex-worker-1")