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:
1 parent
1f7d60563d
commit
502b6e1003
8 files changed
+236
-23
No files matched your search
+49
-5
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user