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)
|
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
|
||||||
if save_detail_enrichment(db, listing_id, enrichment):
|
if save_detail_enrichment(db, listing_id, enrichment):
|
||||||
counters.enriched += 1
|
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_blocks = 0
|
||||||
consecutive_soft = 0
|
consecutive_soft = 0
|
||||||
|
|
||||||
|
|
@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
|
||||||
# Прогон 5399 умер именно так: три «блока» подряд, из них два
|
# Прогон 5399 умер именно так: три «блока» подряд, из них два
|
||||||
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
|
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
|
||||||
if _caused_by_empty_pool(e):
|
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(
|
logger.error(
|
||||||
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||||
"к площадке не ходили. enriched=%d attempted=%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