fixup(browser): source-fallback routing, lazy launch, test stability (#1793 review)
This commit is contained in:
parent
77ae4ca779
commit
1578dd5a5d
2 changed files with 55 additions and 23 deletions
|
|
@ -17,7 +17,7 @@ WS-сервер) несовместим с современными версия
|
|||
Per-provider модель (#1793):
|
||||
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
|
||||
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
|
||||
определяется по host URL запроса (или явному полю body["provider"]). Каждый
|
||||
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
|
||||
инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет
|
||||
geoip=True — фингерпринт привязан к гео конкретного прокси, поэтому смешивать
|
||||
источники в одном браузере нельзя.
|
||||
|
|
@ -203,6 +203,7 @@ async def _lock_for(provider: str) -> asyncio.Lock:
|
|||
return existing
|
||||
assert _locks_guard is not None, "_locks_guard not initialised"
|
||||
async with _locks_guard:
|
||||
# setdefault идемпотентен — вторая корутина под guard получит уже созданный лок.
|
||||
return _locks.setdefault(provider, asyncio.Lock())
|
||||
|
||||
|
||||
|
|
@ -304,11 +305,19 @@ async def _ensure_browser(provider: str) -> bool:
|
|||
Returns:
|
||||
True если браузер доступен (был или поднялся), False если launch не удался
|
||||
(прокси лежит) — caller отдаёт 503.
|
||||
|
||||
При неудачном lazy-launch (прокси лёг) запускает фоновую retry-задачу для
|
||||
ЭТОГО поставщика: при eager-startup её ставил _on_startup, в lazy-режиме —
|
||||
первый обратившийся /fetch. Поставщики независимы — фоновый retry поднимается
|
||||
только для тех, к кому был запрос, а не для всех сразу.
|
||||
"""
|
||||
if _browsers.get(provider) is not None:
|
||||
return True
|
||||
await _try_launch_browser(provider)
|
||||
return _browsers.get(provider) is not None
|
||||
if _browsers.get(provider) is not None:
|
||||
return True
|
||||
_start_retry_task(provider)
|
||||
return False
|
||||
|
||||
|
||||
async def _browser_retry_loop(provider: str) -> None:
|
||||
|
|
@ -351,28 +360,22 @@ def _start_retry_task(provider: str) -> None:
|
|||
|
||||
|
||||
async def _on_startup(app: web.Application) -> None:
|
||||
# Startup ТОЛЬКО инициализирует guard для ленивого создания локов — браузеры
|
||||
# НЕ поднимаются здесь. Lazy-launch: инстанс конкретного поставщика стартует на
|
||||
# первом /fetch (или /login) к нему через _ensure_browser под _locks[provider].
|
||||
#
|
||||
# Eager-launch всех 4 на старте давал бы 4 camoufox-инстанса сразу → риск OOM:
|
||||
# до 4 camoufox-инстансов (avito/cian/yandex/generic), lazy; ~0.5-1ГБ RSS
|
||||
# каждый, mem_limit контейнера 5g. Resilience (фоновый retry при недоступном
|
||||
# прокси) запускается при первом обращении к поставщику, не для всех сразу.
|
||||
global _locks_guard
|
||||
_locks_guard = asyncio.Lock()
|
||||
logger.info(
|
||||
"tradein-browser: per-provider concurrency — параллель между %s, ≤1 на поставщика",
|
||||
"tradein-browser: per-provider concurrency (lazy launch) — "
|
||||
"параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию",
|
||||
list(PROVIDERS),
|
||||
)
|
||||
|
||||
# Eager-launch каждого поставщика. Launch НЕ фатален: если прокси лежит,
|
||||
# camoufox бросит InvalidProxy. Раньше это валило startup → exit(1) →
|
||||
# restart-loop. Теперь ловим, остаёмся Up и ретраим в фоне. Поставщики
|
||||
# независимы: лёгший avito-прокси не мешает подняться cian/yandex.
|
||||
for provider in PROVIDERS:
|
||||
lock = await _lock_for(provider)
|
||||
async with lock:
|
||||
launched = await _try_launch_browser(provider)
|
||||
if not launched:
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: старт без браузера (launch failed) — запуск фонового retry",
|
||||
provider,
|
||||
)
|
||||
_start_retry_task(provider)
|
||||
|
||||
|
||||
async def _on_cleanup(app: web.Application) -> None:
|
||||
# Отменяем все фоновые retry-задачи.
|
||||
|
|
@ -400,8 +403,17 @@ async def _on_cleanup(app: web.Application) -> None:
|
|||
|
||||
|
||||
def _resolve_provider(body: dict, url: str) -> str:
|
||||
"""Выбирает поставщика: явный body["provider"] (если валиден) иначе по host URL."""
|
||||
explicit = body.get("provider")
|
||||
"""Выбирает поставщика: явный body["provider"]/body["source"] (если валиден) иначе host.
|
||||
|
||||
Caller (browser_fetcher.py) шлёт {"url","source"} — поэтому explicit-значение
|
||||
берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS;
|
||||
иначе host-detect; иначе generic.
|
||||
|
||||
# domclick (source='domklik') не имеет выделенного BROWSER_PROXY_DOMCLICK в prod
|
||||
# → generic. Намеренно. ('domklik' нет в PROVIDERS → fallthrough на host-detect
|
||||
# domclick.ru → generic.)
|
||||
"""
|
||||
explicit = body.get("provider") or body.get("source")
|
||||
if isinstance(explicit, str) and explicit in PROVIDERS:
|
||||
return explicit
|
||||
return _provider_for_url(url)
|
||||
|
|
@ -420,7 +432,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
"""POST /fetch {"url": "..."} → {"html": "..."}
|
||||
|
||||
Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы.
|
||||
Поставщик определяется по host URL (или явному body["provider"]). Берётся
|
||||
Поставщик определяется по host URL (или явному body["provider"]/["source"]). Берётся
|
||||
ТОЛЬКО лок этого поставщика → разные поставщики работают параллельно, внутри
|
||||
поставщика — строго ≤1 fetch.
|
||||
"""
|
||||
|
|
@ -590,7 +602,7 @@ async def login_handler(request: web.Request) -> web.Response:
|
|||
Выполняет вход на указанную страницу (email + пароль), ждёт появления
|
||||
success_cookie, возвращает полный список cookies браузерного контекста.
|
||||
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL
|
||||
или явному body["provider"]).
|
||||
или явному body["provider"]/["source"]).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
|
|
|||
|
|
@ -148,6 +148,23 @@ def test_resolve_provider_invalid_explicit_falls_back_to_host() -> None:
|
|||
assert server._resolve_provider({"provider": 123}, "https://www.cian.ru/x") == "cian"
|
||||
|
||||
|
||||
def test_resolve_provider_uses_source_field() -> None:
|
||||
"""browser_fetcher шлёт {"url","source"} — explicit берётся и из "source"."""
|
||||
assert server._resolve_provider({"source": "avito"}, "https://example.com/x") == "avito"
|
||||
assert server._resolve_provider({"source": "yandex"}, "https://www.avito.ru/x") == "yandex"
|
||||
|
||||
|
||||
def test_resolve_provider_domclick_source_to_generic() -> None:
|
||||
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
|
||||
|
||||
'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: в prod
|
||||
нет выделенного BROWSER_PROXY_DOMCLICK.
|
||||
"""
|
||||
assert (
|
||||
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
|
||||
)
|
||||
|
||||
|
||||
# ── _lock_for: один лок на поставщика ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -239,7 +256,10 @@ def test_different_providers_fetch_concurrently(monkeypatch: pytest.MonkeyPatch)
|
|||
|
||||
responses = asyncio.run(_run())
|
||||
assert all(r.status == 200 for r in responses)
|
||||
assert probe.global_peak == 3, "три разных поставщика должны фетчить конкурентно"
|
||||
# >=2 (не ==3): на нагруженном CI планировщик может не вывести все три корутины
|
||||
# в overlap одновременно; перекрытие хотя бы двух уже доказывает параллельность
|
||||
# между поставщиками. Same-provider тест (peak==1) остаётся строгим.
|
||||
assert probe.global_peak >= 2, "разные поставщики должны фетчить конкурентно"
|
||||
# У каждого поставщика по одному запросу — внутренний peak == 1.
|
||||
assert probe.peak == {"avito": 1, "cian": 1, "yandex": 1}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue