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:
1 parent
fe509c25c3
commit
7b511d5d38
38 files changed
+2142
-545
No files matched your search
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Actor'ы dramatiq воркера. Точка входа: `dramatiq worker.actors --processes 1 --threads N`.
|
||||
|
||||
Actor'ы синхронные: каждый передаёт работу в общий event loop (поток AsyncIO-middleware), где живут
|
||||
окна браузера, и ждёт её. Так возврат задачи в очередь (enqueue по pika, блокирующий) идёт из потока
|
||||
dramatiq, а не из event loop.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
import dramatiq # noqa: E402
|
||||
from dramatiq.asyncio import get_event_loop_thread # noqa: E402
|
||||
from dramatiq.middleware import Middleware # noqa: E402
|
||||
|
||||
from tasks import store as task_store # noqa: E402
|
||||
from tasks.broker import CLOSE_WINDOW, COMMON_QUEUE, RUN_TASK, broker, personal_queue # noqa: E402
|
||||
from worker.windows import Outcome, WindowPool # noqa: E402
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WORKER_ID = os.getenv("WORKER_ID") or socket.gethostname()
|
||||
# сколько окон Chrome одновременно — и выполняющих задачу, и удерживаемых; ≈ числу ядер (docs/Deployment.md)
|
||||
BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "4"))
|
||||
# через сколько задача без свободного слота снова попадёт в очередь
|
||||
NO_SLOT_DELAY_MS = 2000
|
||||
HEARTBEAT_INTERVAL = 10
|
||||
TASK_TIME_LIMIT_MS = task_store.RUNNING_TTL * 1000
|
||||
|
||||
_pool: WindowPool | None = None
|
||||
_background: asyncio.Task | None = None
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return get_event_loop_thread().run_coroutine(coro)
|
||||
|
||||
|
||||
async def _heartbeat_loop(pool: WindowPool) -> None:
|
||||
"""heartbeat воркера (по нему api видит, жив ли воркер с окном) и закрытие простаивающих окон."""
|
||||
while True:
|
||||
try:
|
||||
await pool.report()
|
||||
await pool.expire_idle()
|
||||
except Exception:
|
||||
log.warning("idle check failed", exc_info=True)
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
|
||||
async def _start() -> None:
|
||||
global _pool, _background
|
||||
_pool = WindowPool(task_store.TaskStore(task_store.connect()), WORKER_ID, BROWSER_SLOTS)
|
||||
await _pool.report()
|
||||
_background = asyncio.create_task(_heartbeat_loop(_pool))
|
||||
log.info("worker %s: %d browser slots, queues %s, %s", WORKER_ID, BROWSER_SLOTS, COMMON_QUEUE, personal_queue(WORKER_ID))
|
||||
|
||||
|
||||
async def _stop() -> None:
|
||||
if _background:
|
||||
_background.cancel()
|
||||
await _pool.close_all()
|
||||
await _pool.store.remove_worker(WORKER_ID)
|
||||
|
||||
|
||||
class Lifecycle(Middleware):
|
||||
"""Пул окон и heartbeat — в event loop AsyncIO-middleware: он поднимается раньше (middleware
|
||||
добавлен в брокер раньше), а окна закрываются до его остановки."""
|
||||
|
||||
def before_worker_boot(self, broker, worker):
|
||||
_run(_start())
|
||||
|
||||
def before_worker_shutdown(self, broker, worker):
|
||||
_run(_stop())
|
||||
|
||||
|
||||
broker.add_middleware(Lifecycle())
|
||||
broker.declare_queue(personal_queue(WORKER_ID))
|
||||
|
||||
|
||||
@dramatiq.actor(actor_name=RUN_TASK, queue_name=COMMON_QUEUE, max_retries=0, time_limit=TASK_TIME_LIMIT_MS)
|
||||
def run_task(task_id: str, session_id: str, payload: dict, idle_timeout: int) -> None:
|
||||
if _run(_pool.handle(task_id, session_id, payload, idle_timeout)) == Outcome.NO_SLOT:
|
||||
# в общую очередь: окно могло открыться на любом воркере, где есть слот
|
||||
run_task.send_with_options(args=(task_id, session_id, payload, idle_timeout), delay=NO_SLOT_DELAY_MS)
|
||||
|
||||
|
||||
@dramatiq.actor(actor_name=CLOSE_WINDOW, queue_name=COMMON_QUEUE, max_retries=0)
|
||||
def close_window(session_id: str) -> None:
|
||||
_run(_pool.close(session_id))
|
||||
Reference in new issue
Block a user