Files
robotex/tests/unit/test_store.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

104 lines
4.2 KiB
Python

import fakeredis
import pytest
from tasks import store as task_store
from tasks.store import Status, TaskStore
pytestmark = pytest.mark.anyio
@pytest.fixture
def store():
return TaskStore(fakeredis.aioredis.FakeRedis(decode_responses=True))
async def test_claim_once(store):
await store.create("t", "s", "https://example.com")
await store.heartbeat("w1", {})
assert (await store.get("t"))["status"] == Status.QUEUED
assert await store.claim("t", "w1")
assert not await store.claim("t", "w2")
assert not await store.cancel("t")
async def test_cancelled_task_is_not_claimed(store):
await store.create("t", "s", None)
assert await store.cancel("t")
assert not await store.claim("t", "w1")
assert (await store.get("t"))["status"] == Status.CANCELLED
async def test_missing_task_is_not_claimed(store):
assert not await store.claim("nope", "w1")
async def test_redelivery_while_running_here_is_skipped(store):
await store.create("t", "s", None)
await store.heartbeat("w1", {})
assert await store.claim("t", "w1")
assert not await store.claim("t", "w1", running_here=True)
async def test_task_of_dead_worker_is_reclaimed_limited_times(store):
await store.create("t", "s", None)
await store.heartbeat("w2", {})
assert await store.claim("t", "w1") # w1 умер — heartbeat нет
assert await store.claim("t", "w2") # attempts = 2
await store.remove_worker("w2")
assert not await store.claim("t", "w3")
task = await store.get("t")
assert task["status"] == Status.FAILED and "worker died" in task["error"]
async def test_task_of_live_worker_is_not_reclaimed(store):
await store.create("t", "s", None)
await store.heartbeat("w1", {})
assert await store.claim("t", "w1")
assert not await store.claim("t", "w2")
async def test_finish_sets_result_and_ttl(store):
await store.create("t", "s", None)
assert await store.redis.ttl("robotex:task:t") == task_store.PENDING_TTL
await store.finish("t", {"status": "ok", "actions": []})
task = await store.get("t")
assert task["status"] == Status.DONE and task["result"] == {"status": "ok", "actions": []}
assert await store.redis.ttl("robotex:task:t") == task_store.RESULT_TTL
async def test_live_window_requires_live_worker(store):
await store.set_session("s", "w1", 5, 300, client_id=7)
assert await store.live_window("s") is None
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")