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):
|
Per-provider модель (#1793):
|
||||||
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
|
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
|
||||||
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
|
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
|
||||||
определяется по host URL запроса (или явному полю body["provider"]). Каждый
|
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
|
||||||
инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет
|
инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет
|
||||||
geoip=True — фингерпринт привязан к гео конкретного прокси, поэтому смешивать
|
geoip=True — фингерпринт привязан к гео конкретного прокси, поэтому смешивать
|
||||||
источники в одном браузере нельзя.
|
источники в одном браузере нельзя.
|
||||||
|
|
@ -203,6 +203,7 @@ async def _lock_for(provider: str) -> asyncio.Lock:
|
||||||
return existing
|
return existing
|
||||||
assert _locks_guard is not None, "_locks_guard not initialised"
|
assert _locks_guard is not None, "_locks_guard not initialised"
|
||||||
async with _locks_guard:
|
async with _locks_guard:
|
||||||
|
# setdefault идемпотентен — вторая корутина под guard получит уже созданный лок.
|
||||||
return _locks.setdefault(provider, asyncio.Lock())
|
return _locks.setdefault(provider, asyncio.Lock())
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -304,11 +305,19 @@ async def _ensure_browser(provider: str) -> bool:
|
||||||
Returns:
|
Returns:
|
||||||
True если браузер доступен (был или поднялся), False если launch не удался
|
True если браузер доступен (был или поднялся), False если launch не удался
|
||||||
(прокси лежит) — caller отдаёт 503.
|
(прокси лежит) — caller отдаёт 503.
|
||||||
|
|
||||||
|
При неудачном lazy-launch (прокси лёг) запускает фоновую retry-задачу для
|
||||||
|
ЭТОГО поставщика: при eager-startup её ставил _on_startup, в lazy-режиме —
|
||||||
|
первый обратившийся /fetch. Поставщики независимы — фоновый retry поднимается
|
||||||
|
только для тех, к кому был запрос, а не для всех сразу.
|
||||||
"""
|
"""
|
||||||
if _browsers.get(provider) is not None:
|
if _browsers.get(provider) is not None:
|
||||||
return True
|
return True
|
||||||
await _try_launch_browser(provider)
|
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:
|
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:
|
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
|
global _locks_guard
|
||||||
_locks_guard = asyncio.Lock()
|
_locks_guard = asyncio.Lock()
|
||||||
logger.info(
|
logger.info(
|
||||||
"tradein-browser: per-provider concurrency — параллель между %s, ≤1 на поставщика",
|
"tradein-browser: per-provider concurrency (lazy launch) — "
|
||||||
|
"параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию",
|
||||||
list(PROVIDERS),
|
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:
|
async def _on_cleanup(app: web.Application) -> None:
|
||||||
# Отменяем все фоновые retry-задачи.
|
# Отменяем все фоновые retry-задачи.
|
||||||
|
|
@ -400,8 +403,17 @@ async def _on_cleanup(app: web.Application) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _resolve_provider(body: dict, url: str) -> str:
|
def _resolve_provider(body: dict, url: str) -> str:
|
||||||
"""Выбирает поставщика: явный body["provider"] (если валиден) иначе по host URL."""
|
"""Выбирает поставщика: явный body["provider"]/body["source"] (если валиден) иначе host.
|
||||||
explicit = body.get("provider")
|
|
||||||
|
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:
|
if isinstance(explicit, str) and explicit in PROVIDERS:
|
||||||
return explicit
|
return explicit
|
||||||
return _provider_for_url(url)
|
return _provider_for_url(url)
|
||||||
|
|
@ -420,7 +432,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
"""POST /fetch {"url": "..."} → {"html": "..."}
|
"""POST /fetch {"url": "..."} → {"html": "..."}
|
||||||
|
|
||||||
Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы.
|
Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы.
|
||||||
Поставщик определяется по host URL (или явному body["provider"]). Берётся
|
Поставщик определяется по host URL (или явному body["provider"]/["source"]). Берётся
|
||||||
ТОЛЬКО лок этого поставщика → разные поставщики работают параллельно, внутри
|
ТОЛЬКО лок этого поставщика → разные поставщики работают параллельно, внутри
|
||||||
поставщика — строго ≤1 fetch.
|
поставщика — строго ≤1 fetch.
|
||||||
"""
|
"""
|
||||||
|
|
@ -590,7 +602,7 @@ async def login_handler(request: web.Request) -> web.Response:
|
||||||
Выполняет вход на указанную страницу (email + пароль), ждёт появления
|
Выполняет вход на указанную страницу (email + пароль), ждёт появления
|
||||||
success_cookie, возвращает полный список cookies браузерного контекста.
|
success_cookie, возвращает полный список cookies браузерного контекста.
|
||||||
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL
|
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL
|
||||||
или явному body["provider"]).
|
или явному body["provider"]/["source"]).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
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"
|
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: один лок на поставщика ────────────────────────────────────────────
|
# ── _lock_for: один лок на поставщика ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -239,7 +256,10 @@ def test_different_providers_fetch_concurrently(monkeypatch: pytest.MonkeyPatch)
|
||||||
|
|
||||||
responses = asyncio.run(_run())
|
responses = asyncio.run(_run())
|
||||||
assert all(r.status == 200 for r in responses)
|
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.
|
# У каждого поставщика по одному запросу — внутренний peak == 1.
|
||||||
assert probe.peak == {"avito": 1, "cian": 1, "yandex": 1}
|
assert probe.peak == {"avito": 1, "cian": 1, "yandex": 1}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue