fix(tradein/scrapers): диагноз блока терялся при схлопывании, а в алерт шла непроверенная причина #3183

Merged
bot-backend merged 1 commit from fix/3178-honest-ban-kind into main 2026-08-28 17:15:03 +00:00
6 changed files with 225 additions and 15 deletions

View file

@ -33,6 +33,7 @@ from __future__ import annotations
import json
import logging
from collections import Counter
from collections.abc import Callable, Collection, Mapping
from functools import cache
from typing import Any
@ -819,6 +820,32 @@ def mark_banned(
_alert_on_run_id(db, run_id)
def _dominant_ban_kind(census: Mapping[str, int]) -> str:
"""Диагноз по переписи блоков прогона: kind -> сколько раз он встретился (#3178).
Раньше вызывающий код терял кратности до вызова (`set()`), поэтому 4 блока
'platform' + 1 'infra' и 2+2 давали функции один и тот же вход {'platform',
'infra'} неотличимые случаи, хотя первый явно платформенный, а второй
действительно спорный. Перепись приходит уже с кратностями (Counter), здесь
только выбор:
- пусто 'unknown' (диагнозов не было вовсе);
- один вид он, независимо от количества;
- несколько видов, но один строго больше половины всех блоков он
(доминирующий диагноз, единичные выбросы других типов его не размывают);
- иначе (нет строгого большинства) 'unknown' по какой причине оборвался
именно этот прогон, честно не знаем.
"""
if not census:
return BAN_KIND_UNKNOWN
if len(census) == 1:
return next(iter(census))
total = sum(census.values())
kind, count = max(census.items(), key=lambda kv: kv[1])
if count > total / 2:
return kind
return BAN_KIND_UNKNOWN
def mark_backfill_finished(
db: Session,
run_id: int,
@ -827,7 +854,7 @@ def mark_backfill_finished(
source: str,
aborted_by_blocks: bool = False,
fail_hint: str | None = None,
ban_kinds: Collection[str] = (),
ban_kinds: Collection[str] | Mapping[str, int] = (),
) -> None:
"""Честный финал detail-backfill'а (#2674): нулевой прогон ≠ 'done'.
@ -860,11 +887,18 @@ def mark_backfill_finished(
контейнера, то есть на первом же деплое после ночного прогона.
`ban_kinds` диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача
поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами,
в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и
пишется; разошлись (или их типы ничего не доказывают) 'unknown'. Смешанный
прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что
попалась последней какая из них оборвала прогон, мы не знаем (#2764).
поймала за прогон; пустой (дефолт) = задача типы не различает. Принимает либо
Collection[str] (старые вызовы список/set диагнозов, кратности не несут) либо
уже готовую перепись Mapping[str, int] (kind -> сколько раз). Раньше здесь стоял
set(ban_kinds) терял кратности ДО решения: 4 блока 'platform' + 1 'infra'
схлопывались в тот же вход {'platform', 'infra'}, что и настоящие 2+2, и оба
давали 'unknown' (#3178, прод: 5 прогонов подряд 4×platform+1×infra → unknown,
один прогон 5/5 одного вида platform при том же исключении на каждом блоке,
AvitoBlockedError firewall/soft-block). Перепись кладём в
counters["ban_kinds"] (kind -> count) переживает финализацию наравне с
остальными counters, диагноз строки прогона выбирает _dominant_ban_kind: один
вид он; явное большинство (строго > половины блоков) он; иначе 'unknown',
честно «не знаем, какой из них оборвал прогон» (#2764).
"""
attempted = int(counters.get("attempted") or 0)
enriched = int(counters.get("enriched") or 0)
@ -882,13 +916,18 @@ def mark_backfill_finished(
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
kinds = set(ban_kinds)
# Counter() принимает и Collection (считает элементы — старые set/list-вызовы),
# и Mapping (копирует кратности как есть — census от вызывающего) одним и тем
# же конструктором.
census = Counter(ban_kinds)
if census:
counters["ban_kinds"] = dict(census) # type: ignore[assignment]
mark_banned(
db,
run_id,
reason,
counters,
ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN,
ban_kind=_dominant_ban_kind(census),
)
return

View file

@ -407,10 +407,12 @@ async def run_avito_detail_backfill(
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
# в отличие от логов; см. _failure_signature.
failure_census: Counter[str] = Counter()
# #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один —
# он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает
# mark_backfill_finished, один узел на все три backfill'а).
block_ban_kinds: set[str] = set()
# #2764/#3178: диагнозы всех блоков прогона по ТИПУ исключения, С кратностями
# (Counter, не set) — set терял их до решения: 5 прогонов подряд 4×platform+
# 1×infra и настоящий 2+2 приходили в mark_backfill_finished одинаково и
# получали 'unknown' оба раза, хотя первый явно платформенный. Перепись
# (kind -> count) решает _dominant_ban_kind в scrape_runs.py.
block_ban_kinds: Counter[str] = Counter()
for idx, row in enumerate(snapshot):
# Budget guard
@ -587,7 +589,7 @@ async def run_avito_detail_backfill(
consecutive_blocks += 1
counters.blocked += 1
failure_census[_failure_signature(e)] += 1
block_ban_kinds.add(ban_kind_of_exception(e))
block_ban_kinds[ban_kind_of_exception(e)] += 1
do_sleep = False
logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
@ -602,9 +604,10 @@ async def run_avito_detail_backfill(
if consecutive_blocks >= max_consecutive_blocks:
logger.error(
"avito_detail_backfill: run_id=%d ABORT -- %d consecutive blocks, "
"IP rate-limited. enriched=%d attempted=%d",
"частая причина: %s. enriched=%d attempted=%d",
run_id,
consecutive_blocks,
_top_failure(failure_census) or "причина не определена",
counters.enriched,
counters.attempted,
)

View file

@ -338,9 +338,12 @@ async def run_domclick_detail_backfill(
e,
)
if consecutive_blocks >= max_consecutive_blocks:
# #2764/#3178: DomClickBlockedError не разводит площадку (QRATOR)
# и наш браузерный тракт (см. докстринг класса выше) — причину
# НЕ выдумываем, пишем как есть.
logger.error(
"domclick_detail_backfill: run_id=%d ABORT -- %d consecutive "
"blocks, QRATOR reputation likely burned for the session/proxy. "
"blocks, причина не определена (площадка либо наш тракт). "
"enriched=%d attempted=%d",
run_id,
consecutive_blocks,

View file

@ -243,6 +243,86 @@ async def test_backfill_reports_ban_kind_of_the_blocks_it_saw(
assert set(runs.mark_backfill_finished.call_args.kwargs["ban_kinds"]) == expected_kinds
@pytest.mark.asyncio
async def test_backfill_ban_kinds_census_keeps_multiplicities() -> None:
"""4×AvitoBlockedError + 1×AvitoSidecarUnavailableError -> перепись 4/1, не set (#3178).
Фальсификация: до правки block_ban_kinds был set() -- .add() схлопнул бы этот
прогон в тот же {'platform', 'infra'}, что и настоящие 2+2, и mark_backfill_finished
не смог бы отличить явное большинство от честной ничьей.
"""
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
# abort-check срабатывает ПЕРЕД recovery на 5-м блоке -- ровно 5 попыток.
mock_fetch = AsyncMock(
side_effect=[
AvitoBlockedError("firewall"),
AvitoBlockedError("firewall"),
AvitoBlockedError("firewall"),
AvitoBlockedError("firewall"),
AvitoSidecarUnavailableError("browser unavailable"),
]
)
mock_scraper = MagicMock()
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False)
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER, mock_scraper),
patch(_RUNS, runs),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_avito_detail_backfill(
db, run_id=3, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5}
)
runs.mark_backfill_finished.assert_called_once()
census = runs.mark_backfill_finished.call_args.kwargs["ban_kinds"]
assert dict(census) == {"platform": 4, "infra": 1}
@pytest.mark.asyncio
async def test_backfill_abort_log_has_no_ip_rate_limited_literal(caplog: Any) -> None:
"""ABORT-лог называет измеренную причину, а не литерал 'IP rate-limited' (#3178).
До правки текст ABORT всегда писал 'IP rate-limited' -- даже когда блоки были
отказом НАШЕГО сайдкара (AvitoSidecarUnavailableError), не площадки.
"""
from scraper_kit.avito_exceptions import AvitoBlockedError
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(side_effect=AvitoBlockedError("firewall/soft-block"))
mock_scraper = MagicMock()
mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True)
fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False)
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION, return_value=AsyncMock()),
patch(_SCRAPER, mock_scraper),
patch(_RUNS, runs),
patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://test-proxy.local:8080")),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
caplog.at_level("ERROR"),
):
await run_avito_detail_backfill(
db, run_id=3, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5}
)
abort_records = [r.message for r in caplog.records if "ABORT" in r.message]
assert abort_records, "ожидался ABORT-лог"
assert "IP rate-limited" not in abort_records[0]
assert "AvitoBlockedError" in abort_records[0]
@pytest.mark.asyncio
async def test_backfill_blocked_abort_after_max_consecutive() -> None:
"""5 consecutive AvitoBlockedError -> abort с пометкой aborted_by_blocks (#2674).

View file

@ -249,6 +249,45 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None:
runs.mark_failed.assert_not_called()
@pytest.mark.asyncio
async def test_backfill_abort_log_has_no_qrator_literal(caplog: pytest.LogCaptureFixture) -> None:
"""ABORT-лог не утверждает конкретную причину, которую задача не устанавливает (#3178).
DomClickBlockedError поднимается и на распознанном QRATOR-маркере (площадка), и
на любом другом сбое браузерного fetch (наш тракт) -- см. докстринг класса выше
(#2764: диагноз здесь НЕ установлен). До правки ABORT всегда писал 'QRATOR
reputation likely burned for the session/proxy' -- утверждение, для которого нет
основания в этом прогоне.
"""
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = DomClickBlockedError("browser fetch failed")
mock_fetch = AsyncMock(side_effect=blocked_exc)
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
caplog.at_level("ERROR"),
):
await run_domclick_detail_backfill(
db,
run_id=4,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
abort_records = [r.message for r in caplog.records if "ABORT" in r.message]
assert abort_records, "ожидался ABORT-лог"
assert "QRATOR reputation likely burned" not in abort_records[0]
assert "причина не определена" in abort_records[0]
@pytest.mark.asyncio
async def test_backfill_parse_error_counts_failed_no_abort() -> None:
"""DomClickParseError (schema drift, not a block) -> failed++, consecutive-block

View file

@ -21,6 +21,7 @@
from __future__ import annotations
import os
from collections import Counter
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@ -96,6 +97,51 @@ def test_finalizer_with_mixed_diagnoses_writes_unknown() -> None:
)
# ── 1b. Перепись (#3178): кратности решают, set() их терял ───────────────────
def test_finalizer_census_majority_wins_over_minority() -> None:
"""4×platform + 1×infra → 'platform': явное большинство, не 'unknown' (#3178).
Фальсификация: до правки вызывающий терял кратности через set(ban_kinds) ДО
решения {'platform', 'infra'} от 4+1 был неотличим от настоящего 2+2, и оба
давали 'unknown'. Прод: 5 прогонов подряд с одним и тем же AvitoBlockedError
(firewall/soft-block, browser-mode) 4 блока сошлись в 'platform', 1 в 'infra',
строка прогона получала 'unknown' там, где явное большинство прямо говорило
'platform'.
"""
census = Counter({runs_mod.BAN_KIND_PLATFORM: 4, runs_mod.BAN_KIND_INFRA: 1})
assert _ban_kind_of_finished(ban_kinds=census) == runs_mod.BAN_KIND_PLATFORM
def test_finalizer_census_tie_writes_unknown() -> None:
"""2×platform + 2×infra — ровно поровну, строгого большинства нет → 'unknown'."""
census = Counter({runs_mod.BAN_KIND_PLATFORM: 2, runs_mod.BAN_KIND_INFRA: 2})
assert _ban_kind_of_finished(ban_kinds=census) == runs_mod.BAN_KIND_UNKNOWN
def test_finalizer_census_all_same_kind() -> None:
"""5 из 5 одного вида → он же, вне зависимости от абсолютного счёта блоков."""
census = Counter({runs_mod.BAN_KIND_PLATFORM: 5})
assert _ban_kind_of_finished(ban_kinds=census) == runs_mod.BAN_KIND_PLATFORM
def test_finalizer_census_lands_in_counters() -> None:
"""Перепись (kind -> count) остаётся в counters['ban_kinds'] после выбора
диагноза строки прогона не теряется вместе с решением (#3178)."""
counters = dict(_BLOCKED_RUN)
with patch.object(runs_mod, "mark_banned", lambda *a, **k: None):
runs_mod.mark_backfill_finished(
MagicMock(),
1,
counters,
source="avito_detail_backfill",
aborted_by_blocks=True,
ban_kinds=Counter({runs_mod.BAN_KIND_PLATFORM: 4, runs_mod.BAN_KIND_INFRA: 1}),
)
assert counters["ban_kinds"] == {"platform": 4, "infra": 1}
# ── 2. Диагноз не врёт там, где он передаётся: browser-ветка fetch_detail ────