Свип ДомКлика ходит в тёплом браузерном контексте, а не поднимает камуфокс на каждый фетч
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
build_browser_fetcher получил keyword-only reuse_context (дефолт False — остальные шесть вызывающих не меняются), и свип домклика зовёт фабрику с reuse_context=True. Почему. Свип был единственным, кто остался на холодном пути: сайдкар на каждый /fetch делал browser.new_page() и поднимал новый камуфокс. Прод-замер 17.09, прогон 7389 (Москва): 600 страниц за 3ч05м, 1 корзина из 6, память сайдкара 426 МиБ → 1.79 ГиБ, в логе «запуск AsyncCamoufox» между почти каждым запросом. Якорная вкладка (#3118) в таком режиме не переживает фетч, её поднимают заново через goto(origin), и именно эти goto валятся таймаутом 60с, убивая корзину целиком — так подряд легли '5+', 'st', '1'. Оба detail-backfill'а давно ходят с reuse_context=True; там же записан замер: 26 холодных фетчей = 100% блок, те же карточки в тёплом контексте = 5/5 примерно по 2с. Упавшая корзина сбрасывает контекст. Тёплый контекст, сгоревший на одной корзине, иначе переползает в следующую — ровно этот каскад и наблюдался. Сброс стоит в generic except перед continue, где следующий fetch() его и доставит. В ветке DomClickBlockedError сброса СОЗНАТЕЛЬНО нет: request_context_reset() лишь взводит _context_reset_pending, а тот уезжает в сайдкар ключом reset_context следующего fetch(), которого после break не будет. Остаётся известная дыра — _contexts[provider] в сайдкаре живёт без TTL, так что сожжённый блоком контекст достаётся следующему прогону; закрыть её можно только отдельной ручкой сброса в сайдкаре, это отдельная задача. Три двойника фетчера в тестах дополнены request_context_reset / reuse_context — без этого они падали TypeError/AttributeError, то есть проверяли собственную неполноту вместо поведения свипа. Сьют: 6473 passed, 74 skipped.
This commit is contained in:
parent
dbac5d4e1c
commit
40eadfd169
6 changed files with 185 additions and 2 deletions
|
|
@ -186,6 +186,12 @@ class _FakeFetcher:
|
|||
def report_ban(self, reason: str) -> None:
|
||||
return None
|
||||
|
||||
def request_context_reset(self) -> None:
|
||||
# #3118: свип зовёт сброс тёплого контекста на каждой упавшей корзине —
|
||||
# двойник обязан повторять сигнатуру настоящего фетчера, иначе он проверяет
|
||||
# не поведение свипа, а собственную неполноту.
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
132
tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py
Normal file
132
tradein-mvp/backend/tests/test_3118_domclick_reuse_context.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Свип ДомКлика ходит в тёплый переиспользуемый браузер-контекст сайдкара (#3118).
|
||||
|
||||
Замер (см. `backend/app/tasks/domclick_detail_backfill.py:394-401`): 26 подряд
|
||||
холодных фетчей = 100% QRATOR-блок, те же карточки в тёплом контексте — 5/5
|
||||
примерно по 2с. Причина — `browser.new_page()` создаёт НОВЫЙ изолированный
|
||||
context сайдкара на каждый `/fetch`, из-за чего живой `qrator_jsid2` (куки,
|
||||
которые сайт ротирует через Set-Cookie) никогда не доживает до следующего
|
||||
запроса, а якорная вкладка не выживает между фетчами (`goto(origin)` валился
|
||||
таймаутом 60с, убивая всю корзину).
|
||||
|
||||
Три проверки:
|
||||
1. `build_browser_fetcher(..., reuse_context=True)` включает флаг на фетчере,
|
||||
дефолт (без параметра) — выключен (не ломаем прочие call-site'ы).
|
||||
2. `DomClickScraper.fetch_city` строит фетчер именно с `reuse_context=True`.
|
||||
3. На QRATOR-блоке `fetcher.request_context_reset()` вызывается ДО
|
||||
`fetcher.report_ban()` — иначе сожжённый блоком тёплый контекст травит
|
||||
остаток прогона тем же `qrator_jsid2`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import types
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _fetcher_config() -> types.SimpleNamespace:
|
||||
return types.SimpleNamespace(
|
||||
browser_http_endpoint="http://sidecar:8080",
|
||||
use_proxy_pool_browser=False,
|
||||
environment="test",
|
||||
)
|
||||
|
||||
|
||||
def test_build_browser_fetcher_reuse_context_default_is_off() -> None:
|
||||
"""Без явного параметра поведение прежнее — прочие call-site'ы не меняются."""
|
||||
from scraper_kit.providers._base import build_browser_fetcher
|
||||
|
||||
fetcher = build_browser_fetcher(_fetcher_config(), "domclick")
|
||||
|
||||
assert fetcher._reuse_context is False
|
||||
|
||||
|
||||
def test_build_browser_fetcher_reuse_context_true_sets_flag() -> None:
|
||||
from scraper_kit.providers._base import build_browser_fetcher
|
||||
|
||||
fetcher = build_browser_fetcher(_fetcher_config(), "domclick", reuse_context=True)
|
||||
|
||||
assert fetcher._reuse_context is True
|
||||
|
||||
|
||||
class _FakeFetcher:
|
||||
"""Двойник BrowserFetcher: фиксирует порядок reset/ban."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[Any] = []
|
||||
|
||||
def request_context_reset(self) -> None:
|
||||
self.calls.append("reset")
|
||||
|
||||
def report_ban(self, reason: str) -> None:
|
||||
self.calls.append(("ban", reason))
|
||||
|
||||
|
||||
class _FakeFetcherCM:
|
||||
def __init__(self, fetcher: _FakeFetcher) -> None:
|
||||
self._fetcher = fetcher
|
||||
|
||||
async def __aenter__(self) -> _FakeFetcher:
|
||||
return self._fetcher
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domclick_sweep_builds_fetcher_with_reuse_context() -> None:
|
||||
"""`fetch_city` зовёт фабрику фетчера с `reuse_context=True` (не дефолтом)."""
|
||||
from scraper_kit.domclick_exceptions import DomClickBlockedError
|
||||
from scraper_kit.providers.domclick.serp import DomClickScraper
|
||||
|
||||
fetcher = _FakeFetcher()
|
||||
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
|
||||
|
||||
scraper = DomClickScraper(_fetcher_config())
|
||||
# Обрываем на первом же бакете — детали сбора корзины здесь не проверяются.
|
||||
with (
|
||||
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
|
||||
patch.object(
|
||||
scraper, "_sweep_bucket", AsyncMock(side_effect=DomClickBlockedError("qrator"))
|
||||
),
|
||||
):
|
||||
await scraper.fetch_city(city_id=66, pages=1)
|
||||
|
||||
assert mock_build.call_args.kwargs.get("reuse_context") is True, (
|
||||
f"свип построил фетчер без reuse_context=True: {mock_build.call_args}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domclick_sweep_resets_context_after_failed_bucket() -> None:
|
||||
"""Упавшая корзина сбрасывает тёплый контекст, чтобы он не переполз в следующую.
|
||||
|
||||
Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд легли за две
|
||||
минуты каждая на таймауте goto(origin) — якорная вкладка не поднималась в том же
|
||||
сгоревшем контексте. Без сброса reuse_context=True превращает одну неудачу в
|
||||
цепочку.
|
||||
"""
|
||||
from scraper_kit.providers.domclick.serp import DomClickScraper
|
||||
|
||||
fetcher = _FakeFetcher()
|
||||
mock_build = MagicMock(return_value=_FakeFetcherCM(fetcher))
|
||||
|
||||
scraper = DomClickScraper(_fetcher_config())
|
||||
with (
|
||||
patch("scraper_kit.providers._base.build_browser_fetcher", mock_build),
|
||||
patch.object(scraper, "_sweep_bucket", AsyncMock(side_effect=RuntimeError("boom"))),
|
||||
):
|
||||
await scraper.fetch_city(city_id=66, pages=1)
|
||||
|
||||
assert fetcher.calls.count("reset") == 6, (
|
||||
f"сброс контекста ожидался на каждой из 6 упавших корзин: {fetcher.calls!r}"
|
||||
)
|
||||
assert not any(c != "reset" for c in fetcher.calls), (
|
||||
f"report_ban не должен вызываться на не-QRATOR ошибке: {fetcher.calls!r}"
|
||||
)
|
||||
|
|
@ -43,7 +43,12 @@ def _scraper() -> DomClickScraper:
|
|||
|
||||
class _FakeFetcherCtx:
|
||||
async def __aenter__(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(report_ban=lambda *_a, **_k: None)
|
||||
# request_context_reset (#3118): вызывается ДО report_ban на QRATOR-блоке —
|
||||
# двойник фетчера обязан его иметь, иначе AttributeError на первом же блоке.
|
||||
return SimpleNamespace(
|
||||
report_ban=lambda *_a, **_k: None,
|
||||
request_context_reset=lambda *_a, **_k: None,
|
||||
)
|
||||
|
||||
async def __aexit__(self, *_exc: Any) -> None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -73,7 +73,11 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
|||
proxy_provider: object | None = None,
|
||||
use_pool: bool = False,
|
||||
environment: str = "dev",
|
||||
reuse_context: bool = False,
|
||||
) -> None:
|
||||
# reuse_context (#3118) обязан быть в сигнатуре двойника: фабрика
|
||||
# build_browser_fetcher передаёт его ВСЕГДА, и двойник без него падал бы
|
||||
# TypeError на каждом вызывающем, а не проверял то, ради чего написан.
|
||||
calls.append(
|
||||
{
|
||||
"source": source,
|
||||
|
|
@ -81,6 +85,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
|||
"proxy_provider": proxy_provider,
|
||||
"use_pool": use_pool,
|
||||
"environment": environment,
|
||||
"reuse_context": reuse_context,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ def build_browser_fetcher(
|
|||
*,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
fetch_timeout_s: float | None = None,
|
||||
reuse_context: bool = False,
|
||||
) -> BrowserFetcher:
|
||||
"""Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**.
|
||||
|
||||
|
|
@ -221,6 +222,11 @@ def build_browser_fetcher(
|
|||
(120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);
|
||||
`yandex/newbuilding.py` идёт на дефолтных 120s.
|
||||
|
||||
`reuse_context=False` (дефолт) — сохраняет прежнее поведение всех вызывающих:
|
||||
холодный контекст на каждый /fetch. `reuse_context=True` (#3118, свип домклика)
|
||||
держит один сайдкар-контекст на весь прогон вместо нового камуфокса на каждый
|
||||
фетч.
|
||||
|
||||
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
|
||||
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод → отказ вместо мёртвого
|
||||
env-прокси. `getattr` с дефолтом "dev" — минимальные ScraperConfig-заглушки без поля
|
||||
|
|
@ -234,6 +240,7 @@ def build_browser_fetcher(
|
|||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
environment=environment,
|
||||
reuse_context=reuse_context,
|
||||
)
|
||||
return BrowserFetcher(
|
||||
source=source,
|
||||
|
|
@ -242,6 +249,7 @@ def build_browser_fetcher(
|
|||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
environment=environment,
|
||||
reuse_context=reuse_context,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -522,8 +522,17 @@ class DomClickScraper(BaseScraper):
|
|||
# вживую 09.08: через него 500, через мобильный узел пула — 200 и
|
||||
# snippetsCount=678 в бакете 'st'), поэтому свип брал 0 лотов 4 дня подряд.
|
||||
# proxy_provider=None (тесты/dev) по-прежнему валиден — env-fallback.
|
||||
# reuse_context=True (#3118): без него сайдкар поднимает новый камуфокс на
|
||||
# КАЖДЫЙ /fetch (browser.new_page() создаёт свежий изолированный контекст) —
|
||||
# разовая инъекция cookie выше никогда не видит живой qrator_jsid2, который
|
||||
# сайт ротирует через Set-Cookie (TTL ~2.5ч). Замер: 26 подряд холодных
|
||||
# фетчей = 100% блок, те же карточки в тёплом контексте — 5/5 примерно по 2с
|
||||
# (см. backend/app/tasks/domclick_detail_backfill.py:394-401). Заодно
|
||||
# холодный путь не давал якорной вкладке выжить между фетчами — goto(origin)
|
||||
# валился таймаутом 60с, убивая всю корзину. Тёплый контекст держит один
|
||||
# сайдкар-контекст на весь прогон вместо этого.
|
||||
async with build_browser_fetcher(
|
||||
self._config, "domclick", proxy_provider=self._proxy_provider
|
||||
self._config, "domclick", proxy_provider=self._proxy_provider, reuse_context=True
|
||||
) as fetcher:
|
||||
# Циклический сдвиг: состав корзин прежний, меняется только точка входа.
|
||||
# Отрицательный/большой индекс нормализуем — вызывающий передаёт остаток от
|
||||
|
|
@ -582,6 +591,16 @@ class DomClickScraper(BaseScraper):
|
|||
# прокинут выше) это уже не no-op: узел уходит в
|
||||
# scrape_proxy_source_bans и следующий acquire("domclick") его не
|
||||
# выдаст.
|
||||
# NB: request_context_reset() здесь СОЗНАТЕЛЬНО не зовём. Он лишь
|
||||
# взводит `_context_reset_pending`, а тот уезжает в сайдкар только
|
||||
# ключом `reset_context` СЛЕДУЮЩЕГО fetch() (browser_fetcher.py:565);
|
||||
# `__aexit__` его не сливает. Ниже сразу break — фетчей больше не
|
||||
# будет, флаг умрёт вместе с объектом. Вызов был бы no-op'ом, который
|
||||
# читается как защита.
|
||||
# Дыра остаётся: `_contexts[provider]` в сайдкаре — dict без TTL, так
|
||||
# что сожжённый блоком контекст достанется СЛЕДУЮЩЕМУ прогону. Закрыть
|
||||
# можно только отдельной ручкой сброса в сайдкаре (сейчас там только
|
||||
# /fetch, /fetch-json, /login, /health, /pacing) — отдельной задачей.
|
||||
fetcher.report_ban(f"domklik QRATOR block during rooms={bucket!r}")
|
||||
break
|
||||
except Exception as exc:
|
||||
|
|
@ -595,6 +614,14 @@ class DomClickScraper(BaseScraper):
|
|||
# Exception, а не BaseException — CancelledError (SIGTERM-drain,
|
||||
# watchdog asyncio.wait_for) обязан пройти насквозь.
|
||||
self.fetch_errors += 1
|
||||
# #3118: корзина упала — тёплый контекст (reuse_context=True) мог
|
||||
# сгореть вместе с ней, и тогда он переползёт в следующую корзину.
|
||||
# Прод 17.09, прогон 7389: после падения 'rooms=3' три корзины подряд
|
||||
# ('5+', 'st', '1') легли за две минуты каждая на таймауте
|
||||
# goto(origin) — якорная вкладка не поднималась в том же контексте.
|
||||
# Сброс стоит РОВНО здесь, а не в ветке DomClickBlockedError: там
|
||||
# сразу break и прогон заканчивается, сбрасывать уже нечего.
|
||||
fetcher.request_context_reset()
|
||||
logger.warning(
|
||||
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
|
||||
bucket,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue