Два процесса на одном хосте получали один WORKER_ID и делили персональную очередь: задачу без url в открытое окно забирал процесс без этого окна. Теперь при старте воркер занимает наименьшее свободное имя worker-N (SET NX на heartbeat-ключ), heartbeat продлевает его только при своём instance; если имя занял другой процесс — воркер останавливается. Имена и персональные очереди переиспользуются. WORKER_ID из env убран, в /health — instance, host, pid. make dev при выходе гасит и api, и worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
2.8 KiB
Python
68 lines
2.8 KiB
Python
"""Настоящий воркер 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 db.engine import connect as connect_db
|
|
from worker import actors
|
|
monkeypatch.setattr(actors, "connect_db", lambda: connect_db(f"sqlite+aiosqlite:///{tmp_path / 'robotex.db'}"))
|
|
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 (#110), персональная очередь объявлена до запуска потоков
|
|
assert actors._pool.worker_id == "worker-1"
|
|
redis = submit(actors, server, COMMON_QUEUE if queue == "common" else personal_queue("worker-1"))
|
|
task = redis.hgetall("robotex:task:t1")
|
|
assert task["status"] == "done", task
|
|
assert '"status": "ok"' in task["result"]
|
|
assert redis.exists("robotex:worker:worker-1")
|
|
|
|
|
|
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"] == "worker-1"
|
|
assert not FakeSession.instances[0].closed
|