feature: архитектура v2 — api + очередь + worker, удержание окна браузера (#78)

- Сервис api (FastAPI, без Chrome): POST /v2/tasks, GET /v2/tasks/{id} (доступ по cookie
  сессии), DELETE /v2/sessions/current; /v1/solve — прежний контракт поверх очереди.
- Сервис worker (dramatiq + RabbitMQ): слоты браузера, окна сессий между задачами
  (browser_idle_timeout 0–120 мин, простой — TTL ключа в Redis), heartbeat.
- Маршрутизация задач сессии в персональную очередь воркера с открытым окном.
- Redis: задачи, результаты (RESULT_TTL_HOURS), окна, воркеры. Прокси и ключ 2captcha
  в Redis не сохраняются.
- Движок: AsyncStealthySession.run() — действия на текущей странице без навигации.
- Два образа (robotex-api, robotex-worker), compose с rabbitmq и redis,
  make up/down/logs/smoke, релиз собирает все образы.
- Тесты: api + worker в одном процессе (fakeredis), настоящий воркер dramatiq на
  StubBroker, браузерные тесты на новой схеме, smoke для запущенного стенда.
- Документация: README, API (v2), Deployment, Usage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 19:13:52 +04:00
1 parent fe509c25c3
commit 7b511d5d38
38 files changed
+2142 -545

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
"""
Состояние задач, окон и воркеров в Redis (#78). Сроки жизни — TTL ключей Redis.
* `robotex:task:<id>` (hash) — статус задачи, сессия, время, результат. Пока задача не завершена,
у ключа страховочный TTL `PENDING_TTL`; при завершении — `RESULT_TTL` (`RESULT_TTL_HOURS`).
* `robotex:session:<id>` (hash) — где открыто окно сессии: `worker_id` и `idle_timeout`.
Пока задача выполняется, TTL — `RUNNING_TTL`; после неё — `browser_idle_timeout`. Ключ исчез —
окно закрывается (worker/windows.py).
* `robotex:worker:<id>` — heartbeat воркера с TTL `HEARTBEAT_TTL`: слоты и число окон.
Прокси и ключ 2captcha сюда не попадают: они передаются только в сообщении очереди.
"""
import json
import os
import time
from redis.asyncio import Redis
from redis.exceptions import WatchError
PREFIX = "robotex"
RESULT_TTL = int(float(os.getenv("RESULT_TTL_HOURS", "24")) * 3600)
# задача не может висеть в очереди или выполняться дольше: дальше её ключ просто исчезает
PENDING_TTL = 6 * 3600
# пока задача выполняется, ключ сессии держит маршрут к воркеру; дольше задача не живёт (TimeLimit)
RUNNING_TTL = 3 * 3600
HEARTBEAT_TTL = 30
# сколько раз задачу можно начать заново после падения воркера, прежде чем считать её проваленной
MAX_ATTEMPTS = 2
class Status:
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
CANCELLED = "cancelled"
def connect(url: str | None = None) -> Redis:
return Redis.from_url(url or os.getenv("REDIS_URL", "redis://localhost:6379/0"), decode_responses=True)
def _task_key(task_id: str) -> str:
return f"{PREFIX}:task:{task_id}"
def _session_key(session_id: str) -> str:
return f"{PREFIX}:session:{session_id}"
def _worker_key(worker_id: str) -> str:
return f"{PREFIX}:worker:{worker_id}"
class TaskStore:
def __init__(self, redis: Redis):
self.redis = redis
async def ping(self) -> bool:
return await self.redis.ping()
# --- задачи ---
async def create(self, task_id: str, session_id: str, url: str | None) -> None:
key = _task_key(task_id)
async with self.redis.pipeline(transaction=True) as pipe:
pipe.hset(key, mapping={
"status": Status.QUEUED,
"session_id": session_id,
"url": url or "",
"created_at": time.time(),
"attempts": 0,
})
pipe.expire(key, PENDING_TTL)
await pipe.execute()
async def get(self, task_id: str) -> dict | None:
data = await self.redis.hgetall(_task_key(task_id))
if not data:
return None
if "result" in data:
data["result"] = json.loads(data["result"])
return data
async def claim(self, task_id: str, worker_id: str, running_here: bool = False) -> bool:
"""Взять задачу в работу: queued → running. Атомарно (WATCH), чтобы задачу, отменённую
/v1/solve по таймауту очереди, никто не начал выполнять.
Задачу в статусе running можно взять заново, если её воркер умер (RabbitMQ вернул
неподтверждённое сообщение в очередь) — не больше MAX_ATTEMPTS раз. running_here — задача
уже выполняется в этом процессе: повторная доставка того же сообщения, второй раз не берём."""
key = _task_key(task_id)
async with self.redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(key)
data = await pipe.hgetall(key)
if not data:
return False
status = data["status"]
if status == Status.RUNNING:
owner = data.get("worker_id")
if running_here or (owner != worker_id and await self.worker_alive(owner)):
return False
if int(data.get("attempts", 0)) >= MAX_ATTEMPTS:
pipe.multi()
self._finish(pipe, key, Status.FAILED, error="worker died while running the task")
await pipe.execute()
return False
elif status != Status.QUEUED:
return False
pipe.multi()
pipe.hset(key, mapping={"status": Status.RUNNING, "worker_id": worker_id, "started_at": time.time()})
pipe.hincrby(key, "attempts", 1)
await pipe.execute()
return True
except WatchError:
continue
async def cancel(self, task_id: str) -> bool:
"""queued → cancelled; False, если воркер уже взял задачу."""
key = _task_key(task_id)
async with self.redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(key)
if await pipe.hget(key, "status") != Status.QUEUED:
return False
pipe.multi()
self._finish(pipe, key, Status.CANCELLED)
await pipe.execute()
return True
except WatchError:
continue
async def finish(self, task_id: str, result: dict) -> None:
async with self.redis.pipeline(transaction=True) as pipe:
self._finish(pipe, _task_key(task_id), Status.DONE, result=result)
await pipe.execute()
async def fail(self, task_id: str, error: str) -> None:
async with self.redis.pipeline(transaction=True) as pipe:
self._finish(pipe, _task_key(task_id), Status.FAILED, error=error)
await pipe.execute()
@staticmethod
def _finish(pipe, key: str, status: str, result: dict | None = None, error: str | None = None) -> None:
fields = {"status": status, "finished_at": time.time()}
if result is not None:
fields["result"] = json.dumps(result, ensure_ascii=False)
if error is not None:
fields["error"] = error
pipe.hset(key, mapping=fields)
pipe.expire(key, RESULT_TTL)
# --- окна сессий ---
async def set_session(self, session_id: str, worker_id: str, idle_timeout: int, ttl: int) -> None:
key = _session_key(session_id)
async with self.redis.pipeline(transaction=True) as pipe:
pipe.hset(key, mapping={"worker_id": worker_id, "idle_timeout": idle_timeout})
pipe.expire(key, ttl)
await pipe.execute()
async def get_session(self, session_id: str) -> dict | None:
return await self.redis.hgetall(_session_key(session_id)) or None
async def delete_session(self, session_id: str) -> None:
await self.redis.delete(_session_key(session_id))
async def session_ttl(self, session_id: str) -> int:
return await self.redis.ttl(_session_key(session_id))
async def live_window(self, session_id: str) -> dict | None:
"""Окно сессии, если оно открыто на живом воркере: {"worker_id", "idle_timeout"}."""
window = await self.get_session(session_id)
if window and await self.worker_alive(window["worker_id"]):
return window
return None
# --- воркеры ---
async def heartbeat(self, worker_id: str, info: dict) -> None:
await self.redis.set(_worker_key(worker_id), json.dumps(info), ex=HEARTBEAT_TTL)
async def remove_worker(self, worker_id: str) -> None:
await self.redis.delete(_worker_key(worker_id))
async def worker_alive(self, worker_id: str | None) -> bool:
return bool(worker_id) and bool(await self.redis.exists(_worker_key(worker_id)))
async def workers(self) -> list[dict]:
result = []
async for key in self.redis.scan_iter(match=_worker_key("*")):
info = await self.redis.get(key)
if info:
result.append({"worker_id": key.rsplit(":", 1)[-1], **json.loads(info)})
return sorted(result, key=lambda w: w["worker_id"])