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

+49 -5
View File
@@ -6,10 +6,13 @@
* `robotex:session:<id>` (hash) — где открыто окно сессии: `worker_id`, `idle_timeout`, `client_id`.
Пока задача выполняется, TTL — `RUNNING_TTL`; после неё — `browser_idle_timeout`. Ключ исчез —
окно закрывается (worker/windows.py).
* `robotex:worker:<id>` — heartbeat воркера с TTL `HEARTBEAT_TTL`: слоты и число окон.
* `robotex:worker:<id>` — heartbeat воркера с TTL `HEARTBEAT_TTL`: слоты, число окон, `instance` (токен
процесса), `host`, `pid`. Ключ — он же аренда имени: имена `worker-1`, `worker-2`, … переиспользуются,
при старте воркер берёт наименьшее свободное (#110), поэтому персональных очередей не больше, чем воркеров.
Прокси и ключ 2captcha сюда не попадают: они передаются только в сообщении очереди.
"""
import itertools
import json
import os
import time
@@ -25,6 +28,7 @@ PENDING_TTL = 6 * 3600
# пока задача выполняется, ключ сессии держит маршрут к воркеру; дольше задача не живёт (TimeLimit)
RUNNING_TTL = 3 * 3600
HEARTBEAT_TTL = 30
WORKER_NAME_PREFIX = "worker-"
# сколько раз задачу можно начать заново после падения воркера, прежде чем считать её проваленной
MAX_ATTEMPTS = 2
@@ -186,11 +190,51 @@ class TaskStore:
# --- воркеры ---
async def heartbeat(self, worker_id: str, info: dict) -> None:
await self.redis.set(_worker_key(worker_id), json.dumps(info), ex=HEARTBEAT_TTL)
async def acquire_worker_name(self, info: dict) -> str:
"""Занять наименьшее свободное имя worker-N. info — heartbeat с instance процесса.
SET NX атомарен: два одновременно стартующих воркера одно имя не возьмут."""
for n in itertools.count(1):
name = f"{WORKER_NAME_PREFIX}{n}"
if await self.redis.set(_worker_key(name), json.dumps(info), nx=True, ex=HEARTBEAT_TTL):
return name
async def remove_worker(self, worker_id: str) -> None:
await self.redis.delete(_worker_key(worker_id))
async def heartbeat(self, worker_id: str, info: dict) -> bool:
"""Продлить heartbeat, только если имя ещё за этим процессом (info["instance"]).
False — имя занял другой процесс: этот воркер его потерял."""
key = _worker_key(worker_id)
async with self.redis.pipeline(transaction=True) as pipe:
while True:
try:
await pipe.watch(key)
current = await pipe.get(key)
if current is not None and json.loads(current).get("instance") != info.get("instance"):
await pipe.unwatch()
return False
pipe.multi()
pipe.set(key, json.dumps(info), ex=HEARTBEAT_TTL)
await pipe.execute()
return True
except WatchError:
continue
async def remove_worker(self, worker_id: str, instance: str | None = None) -> None:
"""Освободить имя. С instance — только если оно ещё за этим процессом."""
key = _worker_key(worker_id)
if instance is None:
await self.redis.delete(key)
return
async with self.redis.pipeline(transaction=True) as pipe:
try:
await pipe.watch(key)
current = await pipe.get(key)
if current is None or json.loads(current).get("instance") != instance:
await pipe.unwatch()
return
pipe.multi()
pipe.delete(key)
await pipe.execute()
except WatchError:
pass
async def worker_alive(self, worker_id: str | None) -> bool:
return bool(worker_id) and bool(await self.redis.exists(_worker_key(worker_id)))
+17 -9
View File
@@ -8,7 +8,7 @@ dramatiq, а не из event loop.
import asyncio
import logging
import os
import socket
import signal
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
@@ -30,7 +30,6 @@ from worker.windows import Outcome, WindowPool # noqa: E402
log = logging.getLogger(__name__)
WORKER_ID = os.getenv("WORKER_ID") or socket.gethostname()
# сколько окон Chrome одновременно — и выполняющих задачу, и удерживаемых; ≈ числу ядер (docs/Deployment.md)
BROWSER_SLOTS = int(os.getenv("BROWSER_SLOTS", "4"))
# через сколько задача без свободного слота снова попадёт в очередь
@@ -68,14 +67,23 @@ async def _cleanup_loop(pool: WindowPool) -> None:
await asyncio.sleep(PROFILE_CLEANUP_INTERVAL)
async def _start() -> None:
def _stop_self() -> None:
"""Имя воркера занял другой процесс: штатная остановка, как по Ctrl+C. dramatiq при выходе дочернего
процесса останавливается целиком, Docker (restart: unless-stopped) запустит воркер заново с новым именем."""
os.kill(os.getpid(), signal.SIGTERM)
async def _start() -> str:
"""Пул окон и имя воркера. Имя (worker-N) — не из env, а свободное в Redis (#110): два процесса на одном
хосте не делят персональную очередь, а имена и очереди переиспользуются после перезапусков."""
global _pool
db = connect_db()
await db.init()
_pool = WindowPool(task_store.TaskStore(task_store.connect()), WORKER_ID, BROWSER_SLOTS, Journal(db))
await _pool.report()
_pool = WindowPool(task_store.TaskStore(task_store.connect()), None, BROWSER_SLOTS, Journal(db), on_lost=_stop_self)
worker_id = await _pool.register()
_background.extend([asyncio.create_task(_heartbeat_loop(_pool)), asyncio.create_task(_cleanup_loop(_pool))])
log.info("worker %s: %d browser slots, queues %s, %s", WORKER_ID, BROWSER_SLOTS, COMMON_QUEUE, personal_queue(WORKER_ID))
log.info("worker %s: %d browser slots, queues %s, %s", worker_id, BROWSER_SLOTS, COMMON_QUEUE, personal_queue(worker_id))
return worker_id
async def _stop() -> None:
@@ -83,7 +91,7 @@ async def _stop() -> None:
task.cancel()
_background.clear()
await _pool.close_all()
await _pool.store.remove_worker(WORKER_ID)
await _pool.unregister()
if _pool.journal.db:
await _pool.journal.db.close()
@@ -93,14 +101,14 @@ class Lifecycle(Middleware):
добавлен в брокер раньше), а окна закрываются до его остановки."""
def before_worker_boot(self, broker, worker):
_run(_start())
# персональная очередь объявляется до запуска потоков воркера: dramatiq подпишется и на неё
broker.declare_queue(personal_queue(_run(_start())))
def before_worker_shutdown(self, broker, worker):
_run(_stop())
broker.add_middleware(Lifecycle())
broker.declare_queue(personal_queue(WORKER_ID))
@dramatiq.actor(actor_name=RUN_TASK, queue_name=COMMON_QUEUE, max_retries=0, time_limit=TASK_TIME_LIMIT_MS)
+34 -3
View File
@@ -18,11 +18,13 @@ import base64
import logging
import os
import shutil
import socket
import time
import uuid
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, Callable
from urllib.parse import urlsplit
from patchright.async_api import Page, ProxySettings
@@ -162,10 +164,18 @@ def build_result(payload: TaskRequest, result: dict) -> dict:
class WindowPool:
def __init__(self, store: TaskStore, worker_id: str, slots: int, journal: Journal | None = None):
def __init__(
self, store: TaskStore, worker_id: str | None, slots: int, journal: Journal | None = None,
on_lost: Callable[[], None] | None = None,
):
"""worker_id None — имя берётся в Redis при register() (#110).
on_lost — вызывается, если имя занял другой процесс: такому воркеру надо остановиться."""
self.store = store
self.journal = journal or Journal(None)
self.worker_id = worker_id
self.instance = uuid.uuid4().hex
self.on_lost = on_lost
self.lost = False
self.slots = slots
self.used = 0
self.windows: dict[str, Window] = {}
@@ -178,8 +188,19 @@ class WindowPool:
"free_slots": self.slots - self.used,
"windows": len(self.windows),
"running": len(self.running),
"instance": self.instance,
"host": socket.gethostname(),
"pid": os.getpid(),
}
async def register(self) -> str:
"""Занять свободное имя worker-N: оно же — персональная очередь воркера."""
self.worker_id = await self.store.acquire_worker_name(self.info())
return self.worker_id
async def unregister(self) -> None:
await self.store.remove_worker(self.worker_id, self.instance)
@asynccontextmanager
async def _session_lock(self, session_id: str):
entry = self._locks.setdefault(session_id, _SessionLock())
@@ -194,10 +215,20 @@ class WindowPool:
async def report(self) -> None:
"""heartbeat со слотами сразу после их изменения: по нему api решает, отвечать ли 503 на /v1/solve."""
if self.lost:
return
try:
await self.store.heartbeat(self.worker_id, self.info())
owned = await self.store.heartbeat(self.worker_id, self.info())
except Exception:
log.warning("heartbeat failed", exc_info=True)
return
if not owned:
# Redis был недоступен дольше HEARTBEAT_TTL, и имя занял другой процесс: задачи его очереди
# теперь не наши — останавливаемся, после перезапуска возьмём свободное имя
self.lost = True
log.error("worker name %s is taken by another process, stopping", self.worker_id)
if self.on_lost:
self.on_lost()
def _busy(self, session_id: str) -> bool:
entry = self._locks.get(session_id)