fix(tradein/domclick): обрыв «пул пуст» и save-False больше не теряют попытку — attempted == сумма исходов #3335
2 changed files with 201 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
179
tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
Normal file
179
tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
Normal file
|
|
@ -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 не должна начислять исход дважды"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue