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

+52
View File
@@ -0,0 +1,52 @@
"""
Брокер dramatiq поверх RabbitMQ (#78) и сообщения, которыми api ставит работу воркерам.
Каждый воркер слушает общую очередь `robotex` и персональную `robotex.worker.<id>`. В персональную
api отправляет задачи сессии, чьё окно открыто на этом воркере (#83), иначе — в общую. Сообщение
адресуется actor'у по имени, поэтому api не импортирует код воркера (и Chrome ему не нужен).
`RABBITMQ_URL=stub://` — брокер в памяти для тестов.
"""
import os
import dramatiq
from dramatiq.brokers.stub import StubBroker
from dramatiq.middleware import AsyncIO
COMMON_QUEUE = "robotex"
RUN_TASK = "run_task"
CLOSE_WINDOW = "close_window"
def personal_queue(worker_id: str) -> str:
return f"{COMMON_QUEUE}.worker.{worker_id}"
def make_broker(url: str) -> dramatiq.Broker:
if url.startswith("stub://"):
broker = StubBroker()
else:
from dramatiq.brokers.rabbitmq import RabbitmqBroker
# confirm_delivery: enqueue возвращается, только когда RabbitMQ принял сообщение
broker = RabbitmqBroker(url=url, confirm_delivery=True)
broker.add_middleware(AsyncIO())
return broker
broker = make_broker(os.getenv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"))
dramatiq.set_broker(broker)
def task_message(
task_id: str, session_id: str, payload: dict, idle_timeout: int, queue: str = COMMON_QUEUE,
) -> dramatiq.Message:
"""payload — SolveRequest/TaskRequest.model_dump(): в нём прокси и ключ 2captcha, поэтому
он живёт только в сообщении, а не в Redis."""
return dramatiq.Message(
queue_name=queue, actor_name=RUN_TASK,
args=(task_id, session_id, payload, idle_timeout), kwargs={}, options={},
)
def close_message(session_id: str, queue: str) -> dramatiq.Message:
return dramatiq.Message(queue_name=queue, actor_name=CLOSE_WINDOW, args=(session_id,), kwargs={}, options={})