- Сервис 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>
200 lines
7.6 KiB
Python
200 lines
7.6 KiB
Python
"""
|
||
Состояние задач, окон и воркеров в 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"])
|