"""Пул прокси: подбор (lease), освобождение, health-трекинг (#2162). АДДИТИВНО. Этот модуль реализует pick/lease/release/health-механику поверх таблицы scrape_proxies (миграция 157, #2161). Ни один боевой скрейпер здесь НЕ подключается — интеграция pick-из-пула вместо env-прокси это отдельные шаги P3/P4. Пока модуль используется только health-checker'ом (run_proxy_healthcheck), который просто гоняет ipify-пробу через каждый прокси и обновляет health-поля. Семантика lease: - scrape_proxies.leased_by IS NULL → прокси свободен. - leased_by = → занят run'ом. - leased_by = NON_RUN_LEASE_MARKER → занят не-run вызовом (health-checker и т.п.), когда run_id не применим. acquire() берёт строку через FOR UPDATE SKIP LOCKED (конкурентные acquire не дерутся за одну строку — второй параллельный вызов пропустит залоченную и возьмёт следующую). Health: - mark_health(ok=True) → consecutive_fails=0, last_ok_at/last_check_at, exit_ip, latency. - mark_health(ok=False) → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси авто-disable (enabled=false), чтобы битый узел выпал из пула. - acquire отфильтровывает enabled=false И consecutive_fails >= MAX_FAILS. psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ from __future__ import annotations import logging import time from dataclasses import dataclass import httpx from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) __all__ = [ "DISABLE_THRESHOLD", "MAX_CONSECUTIVE_FAILS", "NON_RUN_LEASE_MARKER", "STALE_LEASE_MINUTES", "ProxyLease", "acquire", "mark_health", "reap_stale_leases", "release", "run_proxy_healthcheck", ] # ── Пороги ─────────────────────────────────────────────────────────────────── # Прокси с >= MAX_CONSECUTIVE_FAILS подряд-фейлами не выдаётся acquire'ом (даже если # ещё enabled) — «карантин» до первого успешного health-check'а (mark_health сбросит # счётчик в 0). Мягче, чем disable: узел может ожить. MAX_CONSECUTIVE_FAILS = 3 # При достижении этого порога подряд-фейлов прокси авто-disable (enabled=false) — # оператор включит вручную после разбора. Строго >= MAX_CONSECUTIVE_FAILS. DISABLE_THRESHOLD = 5 # Lease старше этого времени считается протухшим (упавший sweep не вызвал release) и # освобождается reap_stale_leases — иначе прокси навсегда «занят» мёртвым run'ом. STALE_LEASE_MINUTES = 30 # Маркер lease для не-run вызовов (leased_by NOT NULL = занят, но это не id из scrape_runs). NON_RUN_LEASE_MARKER = -1 # URL для health-пробы: возвращает exit-IP JSON'ом. Тот же эндпоинт, что и admin # /scraper/health (_probe_current_ip). _HEALTH_PROBE_URL = "https://api.ipify.org" _HEALTH_PROBE_TIMEOUT_S = 10.0 @dataclass class ProxyLease: """Арендованный прокси. url несёт схему (http:// / socks5://) — готов для httpx proxy=.""" id: int url: str kind: str rotate_url: str | None def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLease | None: """Взять свободный здоровый прокси под провайдера (avito/cian/yandex/generic/any). SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 отбирает enabled-прокси с приемлемым health (consecutive_fails < MAX_CONSECUTIVE_FAILS), affinity=provider ИЛИ 'any', ещё не арендованный (leased_by IS NULL), предпочитая давно не проверенные (last_ok_at NULLS LAST). Затем помечает строку leased_by=run_id (или NON_RUN_LEASE_MARKER если run_id не задан) и коммитит. Конкурентные acquire не дерутся за одну строку: SKIP LOCKED пропускает залоченную другим вызовом строку, второй параллельный acquire берёт следующую свободную. Returns ProxyLease или None если свободных здоровых прокси нет. """ lease_marker = run_id if run_id is not None else NON_RUN_LEASE_MARKER row = ( db.execute( text( """ SELECT id, url, kind, rotate_url FROM scrape_proxies WHERE enabled AND consecutive_fails < CAST(:max_fails AS integer) AND provider_affinity IN (:provider, 'any') AND leased_by IS NULL ORDER BY last_ok_at NULLS LAST, id FOR UPDATE SKIP LOCKED LIMIT 1 """ ), {"max_fails": MAX_CONSECUTIVE_FAILS, "provider": provider}, ) .mappings() .fetchone() ) if row is None: db.rollback() # снять FOR UPDATE-транзакцию (ничего не залочено, но чисто) return None proxy_id = int(row["id"]) db.execute( text( """ UPDATE scrape_proxies SET leased_by = CAST(:run_id AS bigint), leased_at = now() WHERE id = CAST(:id AS bigint) """ ), {"run_id": lease_marker, "id": proxy_id}, ) db.commit() logger.info( "proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker ) return ProxyLease( id=proxy_id, url=str(row["url"]), kind=str(row["kind"]), rotate_url=row["rotate_url"], ) def release(db: Session, proxy_id: int) -> None: """Освободить прокси (leased_by/leased_at → NULL). Идемпотентно (0-row если уже свободен).""" db.execute( text( """ UPDATE scrape_proxies SET leased_by = NULL, leased_at = NULL WHERE id = CAST(:id AS bigint) """ ), {"id": proxy_id}, ) db.commit() logger.info("proxy_pool: released proxy id=%d", proxy_id) def mark_health( db: Session, proxy_id: int, ok: bool, *, exit_ip: str | None = None, latency_ms: int | None = None, ) -> None: """Записать результат health-check'а прокси. ok=True → consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/ exit_ip/latency_ms. ok=False → consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси авто-disable (enabled=false). last_check_at обновляется в любом случае. """ if ok: db.execute( text( """ UPDATE scrape_proxies SET consecutive_fails = 0, last_ok_at = now(), last_check_at = now(), exit_ip = CAST(:exit_ip AS text), latency_ms = CAST(:latency_ms AS integer), updated_at = now() WHERE id = CAST(:id AS bigint) """ ), {"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id}, ) else: # consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла). db.execute( text( """ UPDATE scrape_proxies SET consecutive_fails = consecutive_fails + 1, last_check_at = now(), enabled = CASE WHEN consecutive_fails + 1 >= CAST(:disable_threshold AS integer) THEN false ELSE enabled END, updated_at = now() WHERE id = CAST(:id AS bigint) """ ), {"disable_threshold": DISABLE_THRESHOLD, "id": proxy_id}, ) db.commit() logger.info("proxy_pool: mark_health id=%d ok=%s exit_ip=%s", proxy_id, ok, exit_ip) def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: """Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release). Без этого прокси навсегда «занят» мёртвым run'ом и выпадает из пула. Returns число освобождённых прокси. """ rows = db.execute( text( """ UPDATE scrape_proxies SET leased_by = NULL, leased_at = NULL WHERE leased_by IS NOT NULL AND leased_at < now() - make_interval(mins => CAST(:mins AS integer)) RETURNING id """ ), {"mins": older_than_minutes}, ).fetchall() db.commit() if rows: logger.warning("proxy_pool: reaped %d stale lease(s): %s", len(rows), [r.id for r in rows]) return len(rows) async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None]: """GET ipify через прокси (timeout _HEALTH_PROBE_TIMEOUT_S). Returns (ok, exit_ip, latency_ms). ok=False + (None, None) при любой ошибке. url несёт схему (http:// / socks5://) — httpx[socks] обрабатывает оба. """ started = time.monotonic() try: async with httpx.AsyncClient(proxy=url, timeout=_HEALTH_PROBE_TIMEOUT_S) as client: resp = await client.get(_HEALTH_PROBE_URL, params={"format": "json"}) resp.raise_for_status() ip = resp.json().get("ip") latency_ms = int((time.monotonic() - started) * 1000) return True, (str(ip) if ip else None), latency_ms except Exception: logger.warning("proxy_pool: health probe failed proxy=%s", _mask(url), exc_info=True) return False, None, None def _mask(url: str) -> str: """Скрыть пароль в proxy-url для логов (scheme://user:***@host).""" if "@" not in url or "//" not in url: return url scheme, rest = url.split("//", 1) creds, host = rest.split("@", 1) if ":" in creds: user, _pwd = creds.split(":", 1) creds = f"{user}:***" return f"{scheme}//{creds}@{host}" async def run_proxy_healthcheck(db: Session) -> dict[str, int]: """Периодический health-check всех enabled-прокси пула (#2162). Сначала reap_stale_leases (освобождает протухшие lease'ы), затем для каждого enabled-прокси гоняет ipify-пробу через сам прокси и пишет результат через mark_health (успех → сброс fails + свежий exit_ip/latency; фейл → инкремент, авто-disable при DISABLE_THRESHOLD). Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters {reaped, checked, ok, failed}. """ reaped = reap_stale_leases(db) proxies = ( db.execute( text( """ SELECT id, url, kind FROM scrape_proxies WHERE enabled ORDER BY id """ ) ) .mappings() .all() ) checked = 0 ok_count = 0 failed = 0 for row in proxies: proxy_id = int(row["id"]) url = str(row["url"]) ok, exit_ip, latency_ms = await _probe_proxy(url) mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms) checked += 1 if ok: ok_count += 1 else: failed += 1 logger.info( "proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d", reaped, checked, ok_count, failed, ) return {"reaped": reaped, "checked": checked, "ok": ok_count, "failed": failed}