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>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-14 15:02:30 +04:00
1 parent 1f7d60563d
commit 502b6e1003
8 files changed
+236 -23

No files matched your search

+5 -3
View File
@@ -51,15 +51,17 @@ def submit(actors, server, queue, idle=0):
@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))
# имя воркера — первое свободное в 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(f"robotex:worker:{actors.WORKER_ID}")
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"] == actors.WORKER_ID
assert redis.hgetall(f"robotex:session:{SID}")["worker_id"] == "worker-1"
assert not FakeSession.instances[0].closed
+92
View File
@@ -0,0 +1,92 @@
"""Два процесса воркера на одном брокере и 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
+29
View File
@@ -72,3 +72,32 @@ async def test_live_window_requires_live_worker(store):
await store.heartbeat("w1", {"browser_slots": 1, "free_slots": 0})
assert await store.live_window("s") == {"worker_id": "w1", "idle_timeout": "5", "client_id": "7"}
assert await store.workers() == [{"worker_id": "w1", "browser_slots": 1, "free_slots": 0}]
async def test_worker_names_are_unique_and_reused(store):
"""Имена worker-N: занятое не выдаётся, освободившееся — переиспользуется (#110)."""
assert await store.acquire_worker_name({"instance": "a"}) == "worker-1"
assert await store.acquire_worker_name({"instance": "b"}) == "worker-2"
await store.remove_worker("worker-1", "a")
assert await store.acquire_worker_name({"instance": "c"}) == "worker-1"
assert await store.redis.ttl("robotex:worker:worker-1") == task_store.HEARTBEAT_TTL
async def test_heartbeat_of_taken_name_is_refused(store):
"""Имя истекло и его занял другой процесс: старый владелец его не продлит и не удалит."""
assert await store.acquire_worker_name({"instance": "old"}) == "worker-1"
assert await store.heartbeat("worker-1", {"instance": "old", "windows": 1})
await store.redis.delete("robotex:worker:worker-1") # TTL истёк, пока Redis был недоступен
assert await store.acquire_worker_name({"instance": "new"}) == "worker-1"
assert not await store.heartbeat("worker-1", {"instance": "old"})
await store.remove_worker("worker-1", "old")
assert (await store.workers())[0]["instance"] == "new"
async def test_expired_own_name_is_renewed(store):
"""Имя истекло, но никто его не занял — heartbeat просто продлевает его."""
assert await store.acquire_worker_name({"instance": "a"}) == "worker-1"
await store.redis.delete("robotex:worker:worker-1")
assert await store.heartbeat("worker-1", {"instance": "a"})
assert await store.worker_alive("worker-1")