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

+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)