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,63 @@
|
||||
"""Настоящий воркер dramatiq на брокере в памяти: actor'ы, event loop AsyncIO, пул окон и heartbeat."""
|
||||
import asyncio
|
||||
|
||||
import dramatiq
|
||||
import fakeredis
|
||||
import pytest
|
||||
|
||||
from api import actions
|
||||
from tasks import store as task_store
|
||||
from tasks.broker import COMMON_QUEUE, personal_queue, task_message
|
||||
from worker import windows
|
||||
|
||||
from .conftest import FakeSession, fake_execute
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker(monkeypatch, tmp_path):
|
||||
server = fakeredis.FakeServer()
|
||||
monkeypatch.setattr(task_store, "connect", lambda url=None: fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
|
||||
monkeypatch.setattr(windows, "AsyncStealthySession", FakeSession)
|
||||
monkeypatch.setattr(windows, "USER_DATA_ROOT", tmp_path)
|
||||
monkeypatch.setattr(actions, "execute", fake_execute())
|
||||
FakeSession.instances, FakeSession.error = [], None
|
||||
|
||||
from worker import actors
|
||||
w = dramatiq.Worker(actors.broker, worker_timeout=100, worker_threads=2)
|
||||
w.start()
|
||||
try:
|
||||
yield actors, server
|
||||
finally:
|
||||
w.stop()
|
||||
# при остановке воркер закрывает окна и снимает heartbeat
|
||||
assert all(s.closed for s in FakeSession.instances)
|
||||
assert not fakeredis.FakeRedis(server=server).keys("robotex:worker:*")
|
||||
|
||||
|
||||
SID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def submit(actors, server, queue, idle=0):
|
||||
"""То же, что делает api: задача в Redis, сообщение в очередь; ждём, пока воркер её выполнит."""
|
||||
store = task_store.TaskStore(fakeredis.aioredis.FakeRedis(server=server, decode_responses=True))
|
||||
asyncio.run(store.create("t1", SID, "https://example.com"))
|
||||
actors.broker.enqueue(task_message("t1", SID, {"url": "https://example.com", "actions": []}, idle, queue))
|
||||
actors.broker.join(queue)
|
||||
return fakeredis.FakeRedis(server=server, decode_responses=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("queue", ["common", "personal"])
|
||||
def test_task_executed_from_queue(worker, queue):
|
||||
actors, server = worker
|
||||
redis = submit(actors, server, COMMON_QUEUE if queue == "common" else personal_queue(actors.WORKER_ID))
|
||||
task = redis.hgetall("robotex:task:t1")
|
||||
assert task["status"] == "done", task
|
||||
assert '"status": "ok"' in task["result"]
|
||||
assert redis.exists(f"robotex:worker:{actors.WORKER_ID}")
|
||||
|
||||
|
||||
def test_window_held_until_shutdown(worker):
|
||||
actors, server = worker
|
||||
redis = submit(actors, server, COMMON_QUEUE, idle=5)
|
||||
assert redis.hgetall(f"robotex:session:{SID}")["worker_id"] == actors.WORKER_ID
|
||||
assert not FakeSession.instances[0].closed
|
||||
Reference in new issue
Block a user