From e2e7e17f953923bebe7624ea4726514f1ff4ebbe Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:35:48 +0500 Subject: [PATCH] =?UTF-8?q?fix(domclick):=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=20(#3332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода, поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1 (прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев (#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed. Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана, но строки уже нет — попытка была, исхода не было. Closes #3332 --- .../app/tasks/domclick_detail_backfill.py | 22 +++ .../test_3332_domclick_counter_identity.py | 179 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index a6e4a11e..4d35cd87 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -447,6 +447,19 @@ async def run_domclick_detail_backfill( enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies) if save_detail_enrichment(db, listing_id, enrichment): counters.enriched += 1 + else: + # #3332, та же дыра в тождестве, что и у пустого пула: карточку + # забрали, а строки уже нет (удалена/деактивирована между + # снимком и UPDATE) — попытка была, исхода не было. Тихо + # терять её нельзя: расхождение читается как потерянный блок. + counters.failed += 1 + logger.warning( + "domclick_detail_backfill: run_id=%d listing %s — карточка " + "разобрана, но UPDATE не нашёл строку id=%d", + run_id, + source_url, + listing_id, + ) consecutive_blocks = 0 consecutive_soft = 0 @@ -471,6 +484,15 @@ async def run_domclick_detail_backfill( # Прогон 5399 умер именно так: три «блока» подряд, из них два # 500 от сайдкара и один пустой пул, отказов площадки — ноль. if _caused_by_empty_pool(e): + # #3332: попытка уже посчитана в attempted (строка выше), а этот + # выход из цикла шёл мимо ВСЕХ исходов — тождество + # attempted = enriched + failed + blocked ломалось ровно на 1 + # (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом). + # Исход честно failed, а не blocked: к площадке не ходили, это + # отказ нашей стороны — тот же разряд, что у транспортных сбоев + # ниже. Причина не теряется: в записи прогона стоит + # no_proxy_stop=1 и mark_failed с текстом про пул. + counters.failed += 1 logger.error( "domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, " "к площадке не ходили. enriched=%d attempted=%d", diff --git a/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py new file mode 100644 index 00000000..feb35609 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py @@ -0,0 +1,179 @@ +"""Тождество счётчиков domclick_detail_backfill: attempted == сумма исходов (#3332). + +`counters.attempted` инкрементируется ДО попытки, исход дописывается уже в +разборе результата. Обрыв «пул прокси пуст» (`_caused_by_empty_pool` → +`no_proxy_stop = True; break`) уходил из цикла между этими двумя точками, поэтому +текущая попытка оставалась без исхода: прод дал 5 прогонов с +`attempted - (enriched + failed + blocked) == 1`. Расхождение читается как +потерянный отказ площадки, хотя площадку не трогали вовсе. + +Исход выбран `failed`, а не `blocked`: к площадке не ходили, отказала наша +инфраструктура — тот же разряд, что у транспортных сбоев (`_is_transport_failure`, +#3283). Причину прогона несёт `no_proxy_stop=1` + `mark_failed`, счётчик её не +подменяет. + +Вторая дыра того же класса (закрыта здесь же): `save_detail_enrichment` вернул +False — карточка разобрана, но строки уже нет; попытка была, исхода не было. + +Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение». +Харнесс зеркалит tests/test_3196_domclick_ban_kind.py. +""" + +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 +from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402 +from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402 + +from app.tasks.domclick_detail_backfill import ( # noqa: E402 + DomClickDetailBackfillResult, + run_domclick_detail_backfill, +) + +_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail" +_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment" +_RUNS = "app.tasks.domclick_detail_backfill.runs_mod" +_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep" +_SETTINGS = "app.tasks.domclick_detail_backfill.settings" +_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher" +_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc" + + +def _make_snapshot(n: int) -> list[dict]: + return [ + { + "id": i + 1, + "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}", + } + for i in range(n) + ] + + +def _mock_db(snapshot: list[dict]) -> MagicMock: + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = snapshot + db.execute.return_value = sel + return db + + +def _mock_session_svc() -> MagicMock: + svc = MagicMock() + svc.load_session.return_value = {"CAS_ID": "123"} + svc.COOKIE_EXPIRY_WARN_DAYS = 5 + svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30) + return svc + + +def _mock_browser_fetcher_cls() -> MagicMock: + instance = AsyncMock() + instance.__aenter__ = AsyncMock(return_value=instance) + instance.__aexit__ = AsyncMock(return_value=False) + instance.request_context_reset = MagicMock() + return MagicMock(return_value=instance) + + +def _empty_pool_block() -> DomClickBlockedError: + """Как это приходит с прода: fetch_detail заворачивает ЛЮБОЙ сбой фетча в + DomClickBlockedError через `raise ... from exc`, поэтому пустой пул лежит в + __cause__ и снаружи выглядит блокировкой.""" + blocked = DomClickBlockedError("browser fetch failed") + blocked.__cause__ = NoProxyAvailableError("domclick") + return blocked + + +async def _run( + fetch: AsyncMock, *, snapshot: int, save_ok: bool = True +) -> tuple[DomClickDetailBackfillResult, MagicMock]: + db = _mock_db(_make_snapshot(snapshot)) + runs = MagicMock() + with ( + patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")), + patch(_SESSION_SVC, _mock_session_svc()), + patch(_RUNS, runs), + patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()), + patch(_FETCH, fetch), + patch(_SAVE, return_value=save_ok), + patch(_SLEEP, new_callable=AsyncMock), + ): + counters = await run_domclick_detail_backfill( + db, + run_id=3332, + params={"batch_size": snapshot, "budget_sec": 3600, "max_consecutive_blocks": 5}, + ) + return counters, runs + + +def _assert_identity(counters: DomClickDetailBackfillResult, *, expected_attempted: int) -> None: + outcomes = counters.enriched + counters.failed + counters.blocked + assert counters.attempted == expected_attempted, ( + f"attempted={counters.attempted}, ожидали {expected_attempted} попыток" + ) + assert counters.attempted == outcomes, ( + f"тождество нарушено: attempted={counters.attempted}, " + f"сумма исходов={outcomes} (enriched={counters.enriched} " + f"failed={counters.failed} blocked={counters.blocked}), " + f"потеряно {counters.attempted - outcomes} попыток без исхода" + ) + + +@pytest.mark.asyncio +async def test_empty_pool_mid_run_keeps_identity() -> None: + """2 успеха, на 3-й попытке пул пуст → attempted=3 == 2 enriched + 1 failed.""" + fetch = AsyncMock(side_effect=[MagicMock(), MagicMock(), _empty_pool_block()]) + counters, runs = await _run(fetch, snapshot=5) + + _assert_identity(counters, expected_attempted=3) + assert counters.failed == 1, ( + f"failed={counters.failed}: оборванная пустым пулом попытка должна быть " + "засчитана отказом НАШЕЙ стороны" + ) + assert counters.blocked == 0, ( + f"blocked={counters.blocked}: площадка не отказывала, запроса к ней не было — " + "начислять блок значит лгать про причину (#3283)" + ) + # Причина обрыва по-прежнему в записи прогона, а не подменена счётчиком. + runs.mark_failed.assert_called_once() + assert runs.mark_failed.call_args.args[3]["no_proxy_stop"] == 1 + + +@pytest.mark.asyncio +async def test_missing_row_on_save_keeps_identity() -> None: + """save_detail_enrichment вернул False (строки уже нет) → попытка не теряется.""" + fetch = AsyncMock(return_value=MagicMock()) + counters, _ = await _run(fetch, snapshot=2, save_ok=False) + + _assert_identity(counters, expected_attempted=2) + assert counters.enriched == 0 and counters.failed == 2, ( + f"enriched={counters.enriched} failed={counters.failed}: " + "непрошедший UPDATE — не успех и не блок" + ) + + +@pytest.mark.asyncio +async def test_blocked_and_enriched_counted_once() -> None: + """Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу.""" + fetch = AsyncMock( + side_effect=[ + MagicMock(), + DomClickBlockedError("refused", status=403), + MagicMock(), + ] + ) + counters, _ = await _run(fetch, snapshot=3) + + _assert_identity(counters, expected_attempted=3) + assert (counters.enriched, counters.blocked, counters.failed) == (2, 1, 0), ( + f"enriched={counters.enriched} blocked={counters.blocked} failed={counters.failed}, " + "ожидали 2/1/0 — правка #3332 не должна начислять исход дважды" + ) -- 2.45.3