Files
robotex/tests/unit/test_routing.py
T
protokeyandClaude Opus 5.5 502b6e1003 fix: имя воркера — свободное worker-N из Redis, а не hostname (#110)
Два процесса на одном хосте получали один 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>
2026-10-14 15:02:30 +04:00

93 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Два процесса воркера на одном брокере и Redis (#110): у каждого своё имя и своя персональная очередь,
задача без url в открытое окно всегда попадает к воркеру, который держит это окно."""
import itertools
import pytest
import main
from db.journal import Journal
from tasks.broker import COMMON_QUEUE, RUN_TASK, personal_queue
from worker import windows
@pytest.fixture
def two_workers(env, monkeypatch):
"""Два пула вместо пула харнесса; общую очередь разбирают по очереди, как два consumer'а RabbitMQ."""
env.redis.delete("robotex:worker:w1")
pools = [windows.WindowPool(env.store, None, slots=2, journal=Journal(env.db)) for _ in range(2)]
for pool in pools:
env.call(pool.register)
turn = itertools.cycle(pools)
delivered = []
async def dispatch(message):
if message.queue_name == COMMON_QUEUE:
pool = next(turn)
else:
pool = next(p for p in pools if personal_queue(p.worker_id) == message.queue_name)
delivered.append((message.args[1], pool.worker_id))
if message.actor_name == RUN_TASK:
await pool.handle(*message.args)
else:
await pool.close(*message.args)
await pool.report()
monkeypatch.setattr(main, "dispatch", dispatch)
yield pools, delivered
for pool in pools:
env.call(pool.close_all)
def run(env, session_id=None, **body):
env.client.cookies.clear()
if session_id:
env.client.cookies.set("session_id", session_id)
resp = env.client.post("/v2/tasks", json={"actions": [], "browser_idle_timeout": 5, **body})
assert resp.status_code == 202, resp.text
task = env.client.get(f"/v2/tasks/{resp.json()['task_id']}").json()
return resp.cookies.get("session_id") or session_id, task
def test_workers_get_distinct_names(env, two_workers):
pools, _ = two_workers
assert [p.worker_id for p in pools] == ["worker-1", "worker-2"]
workers = env.client.get("/health").json()["workers"]
assert [w["worker_id"] for w in workers] == ["worker-1", "worker-2"]
assert len({w["instance"] for w in workers}) == 2
def test_task_without_url_goes_to_window_owner(env, two_workers):
pools, delivered = two_workers
# окна двух сессий открываются на разных воркерах
sid_a, task = run(env, url="https://example.com/a")
assert task["result"]["status"] == "ok"
sid_b, task = run(env, url="https://example.com/b")
assert task["result"]["status"] == "ok"
owner = {sid: wid for sid, wid in delivered}
assert sorted(owner.values()) == ["worker-1", "worker-2"]
# следующие задачи без url — всегда в окно своей сессии
for _ in range(3):
for sid in (sid_a, sid_b):
_, task = run(env, sid)
assert task["status"] == "done" and task["result"]["status"] == "ok", task
assert delivered[-1] == (sid, owner[sid])
assert all(len(p.windows) == 1 for p in pools)
def test_worker_stops_when_name_is_taken(env, two_workers):
"""Имя истекло (Redis был недоступен) и досталось другому процессу — старый владелец останавливается."""
pools, _ = two_workers
stopped = []
pools[0].on_lost = lambda: stopped.append(True)
env.redis.delete("robotex:worker:worker-1")
newcomer = windows.WindowPool(env.store, None, slots=1)
assert env.call(newcomer.register) == "worker-1"
env.call(pools[0].report)
env.call(pools[0].report)
assert stopped == [True] and pools[0].lost
# при остановке старый владелец не снимает чужой heartbeat
env.call(pools[0].unregister)
assert env.client.get("/health").json()["workers"][0]["instance"] == newcomer.instance