diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 64d2ba74..9030ca16 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -367,11 +367,15 @@ async def _job_yandex_newbuilding_sweep( # исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля. processed = int(counters.get("processed") or 0) succeeded = int(counters.get("succeeded") or 0) - if counters.get("no_proxy_stop"): + if result.no_proxy_stop: # #3197: прогон оборван на пустом пуле — к площадке не ходили вовсе. # Это отказ нашей инфраструктуры, а не «ЖК не разрешились»: называть # такой прогон успешным нельзя, и причина должна быть отличима. - logger.error( + # + # INFO, как у соседей (scheduler.py:175, avito_detail_backfill.py:1050, + # domclick:626): причина уже записана в mark_failed + counters.no_proxy_stop=1, + # а ERROR ставил её в один разряд с падением задачи (logger.exception ниже). + logger.info( "yandex_newbuilding_sweep run_id=%d: пул прокси пуст — прогон оборван " "(обработано %d)", run_id, diff --git a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py index b1e48414..0733798d 100644 --- a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py +++ b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py @@ -85,7 +85,15 @@ class YandexNewbuildingSweepResult: duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int | float]: - return {f.name: getattr(self, f.name) for f in fields(self)} + """Счётчики прогона; `no_proxy_stop` — 1/0, а не JSON true/false. + + Формат один с соседями (`scheduler.py:168` cian, `avito_detail_backfill.py:1043`, + `domclick_detail_backfill.py:610`): SQL-монитор ищет + `counters->>'no_proxy_stop' = '1'` и мимо `true` промахнётся молча. + """ + out: dict[str, int | float] = {f.name: getattr(self, f.name) for f in fields(self)} + out["no_proxy_stop"] = int(self.no_proxy_stop) + return out # ── SQL ──────────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py b/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py index fb10bf8e..cad7237a 100644 --- a/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py +++ b/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py @@ -27,18 +27,21 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services.product_handlers import _job_yandex_newbuilding_sweep +from app.tasks.yandex_newbuilding_sweep import YandexNewbuildingSweepResult def _ctx() -> SimpleNamespace: return SimpleNamespace(runs=MagicMock()) -def _result(processed: int, succeeded: int) -> MagicMock: - r = MagicMock() - r.to_dict = MagicMock( - return_value={"processed": processed, "succeeded": succeeded, "total": 425} - ) - return r +def _result(processed: int, succeeded: int) -> YandexNewbuildingSweepResult: + """Настоящий результат задачи, а не MagicMock. + + У MagicMock любой не заданный атрибут (`no_proxy_stop` из #3197) — truthy, то есть + добавление хендлеру новой ветки по полю результата уводило бы сюда ВСЕ три случая, + и тест краснел бы по причине, не имеющей отношения к #2860. + """ + return YandexNewbuildingSweepResult(processed=processed, succeeded=succeeded, total=425) @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_3197_yandex_newbuilding_proxy_pool_wiring.py b/tradein-mvp/backend/tests/test_3197_yandex_newbuilding_proxy_pool_wiring.py index aa9528d1..64f2a1f5 100644 --- a/tradein-mvp/backend/tests/test_3197_yandex_newbuilding_proxy_pool_wiring.py +++ b/tradein-mvp/backend/tests/test_3197_yandex_newbuilding_proxy_pool_wiring.py @@ -172,4 +172,8 @@ async def test_scheduler_marks_run_failed_on_empty_pool() -> None: _db, run_id, reason, counters = ctx.runs.mark_failed.call_args.args assert run_id == 3197 assert "пул" in reason - assert counters["no_proxy_stop"] is True + # Ровно 1, а не JSON true: SQL-монитор соседей ищет counters->>'no_proxy_stop' = '1' + # (scheduler.py:168 cian, avito_detail_backfill.py:1043, domclick_detail_backfill.py:610). + # bool — подкласс int, поэтому `== 1` один сам по себе зелёный и на True: тип проверяем + # отдельно, иначе красное на откате не наступит. + assert counters["no_proxy_stop"] == 1 and not isinstance(counters["no_proxy_stop"], bool) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py index 81e0aff9..ffdbce5c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -206,16 +206,18 @@ def build_browser_fetcher( `ScraperConfig` невозможно на уровне сигнатуры (не полагаемся на code-review чтобы поймать пропущенный параметр). - `proxy_provider=None` (дефолт) — валидно: часть providers (avito legacy - curl-only путь, domclick, cian/yandex newbuilding) пока не подключены к - browser-proxy-пулу (#2160 P4 wiring gap, отдельный issue) — `use_pool` в - этом случае эффективно игнорируется `BrowserFetcher` (`proxy_provider is - None` → env-fallback, см. `browser_fetcher.py::_pool_proxy`). + `proxy_provider=None` (дефолт) — валидно, но у providers больше не встречается: + после #3382 (cian newbuilding) и #3389 (yandex newbuilding) провайдер передают + ВСЕ call-site'ы providers (avito/cian/domclick/yandex, serp и newbuilding). + Без пула остались только прямые конструкции `BrowserFetcher(...)` мимо этой + фабрики — служебные ручки `app/api/v1/admin.py` (511 cian-login, 702 + domclick-detail-debug); прямые конструкции в `orchestration/pipeline.py` и в + backfill-задачах пул получают. `use_pool` при `proxy_provider is None` эффективно + игнорируется `BrowserFetcher` (env-fallback, см. `browser_fetcher.py::_pool_proxy`). `fetch_timeout_s=None` (дефолт) → используется дефолт `BrowserFetcher` - (120s). Yandex-провайдер передаёt здесь 30s явно (см. вызовы в - `yandex/serp.py`/`yandex/newbuilding.py`) — единственный provider с - отличным от дефолта таймаутом. + (120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s); + `yandex/newbuilding.py` идёт на дефолтных 120s. `environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в `BrowserFetcher._pool_proxy`: пул пуст/сломан + прод → отказ вместо мёртвого