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