Some checks failed
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Failing after 1m36s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 33s
321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""Пул прокси: подбор (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_id> → занят 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}
|