All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m59s
Прошлая правка понижала banned→failed/done по одному диагнозу infra и ломала обратный контракт: нулевой прогон с infra (yandex 5xx #3196, финализатор #2764) получал 'failed' — настоящий бан площадки, опознанный как infra, прятался под «нашу поломку». Хуже исходного дефекта: 2 красных теста в полном прогоне. Понижение сужено до случая прогона 5425 — dominant='infra' И produced > 0: брейкер оборвал по доле, а карточки при этом обогащались → 'done'. Нулевой прогон остаётся 'banned' (честность несёт ban_kind), пустая перепись — тем более: dominant='unknown', статус не трогаем. Тесты: контроль на обратную ошибку (ноль результата → banned+infra) и на пустой census (→ banned+unknown); основной кейс {'infra': 20} при 10 обогащённых — не banned.
373 lines
18 KiB
Python
373 lines
18 KiB
Python
"""Вид блока решает обрыв по доле и статус прогона (#3288, часть B).
|
||
|
||
Прогон 5425 оборвался по доле блоков (`abort_reason='ratio'`, статус `banned`) на
|
||
48 «блоках», из которых 41 был `AvitoSidecarUnavailableError` — отказ НАШЕГО тракта,
|
||
а не площадки: та же карточка не доехала до Авито, и 41 других карточек прогон
|
||
успел честно обогатить. `record_block()` вида не принимал вовсе, поэтому в числитель
|
||
скользящего окна (#3184) падало всё подряд.
|
||
|
||
Проверка ПО ЗНАЧЕНИЮ — сравниваются `abort_reason()`, счётчики и то, какой именно
|
||
финализатор дёрнут (`mark_banned` / `mark_failed` / `mark_done`), а не «не бросило
|
||
исключение». К каждому послаблению идёт контроль на противоположную ошибку: 14
|
||
настоящих `AvitoBlockedError` из 20 обязаны рвать прогон как раньше (#3184 не
|
||
меняется), а пустой пул опознаётся по ЦЕПОЧКЕ `__cause__`, не по подстроке
|
||
«no proxy available» — на подстроке уже обжёгся #3272.
|
||
|
||
Харнесс бэкфилла зеркалит tests/test_3338_backfill_counter_identity.py, оттуда же
|
||
тождество attempted = enriched + blocked + gone + failed.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from typing import Any
|
||
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.avito_exceptions import ( # noqa: E402
|
||
AvitoBlockedError,
|
||
AvitoSidecarUnavailableError,
|
||
)
|
||
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
|
||
|
||
from app.services import scrape_runs as runs_svc # noqa: E402
|
||
from app.services.backfill_block_breaker import BlockRatioBreaker # noqa: E402
|
||
from app.services.scrape_runs import ( # noqa: E402
|
||
BAN_KIND_INFRA,
|
||
BAN_KIND_PLATFORM,
|
||
BAN_KIND_UNKNOWN,
|
||
)
|
||
from app.tasks.avito_detail_backfill import ( # noqa: E402
|
||
AvitoDetailBackfillResult,
|
||
run_avito_detail_backfill,
|
||
)
|
||
|
||
_A_FETCH = "app.tasks.avito_detail_backfill.fetch_detail"
|
||
_A_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment"
|
||
_A_RUNS = "app.tasks.avito_detail_backfill.runs_mod"
|
||
_A_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep"
|
||
_A_SETTINGS = "app.tasks.avito_detail_backfill.settings"
|
||
_A_SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
|
||
_A_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
|
||
_A_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"
|
||
_A_ROTATE_PROXY = "app.tasks.avito_detail_backfill.rotate_proxy"
|
||
|
||
_WINDOW = 20
|
||
_THRESHOLD = 0.7
|
||
|
||
|
||
def _breaker(snapshot_size: int = 100) -> BlockRatioBreaker:
|
||
return BlockRatioBreaker(
|
||
window_size=_WINDOW,
|
||
ratio_threshold=_THRESHOLD,
|
||
safety_min=5,
|
||
snapshot_size=snapshot_size,
|
||
)
|
||
|
||
|
||
# ── брейкер: что попадает в числитель доли ────────────────────────────────────
|
||
|
||
|
||
def test_twenty_sidecar_blocks_do_not_abort_by_ratio() -> None:
|
||
"""20 подряд отказов сайдкара при снапшоте больше окна — не 'ratio' (приёмка)."""
|
||
breaker = _breaker()
|
||
for _ in range(20):
|
||
breaker.record_block(BAN_KIND_INFRA)
|
||
|
||
assert breaker.abort_reason() is None, (
|
||
f"abort_reason={breaker.abort_reason()!r} при 20 infra-отказах: площадка "
|
||
"не отказывала ни разу, рвать прогон не по чему"
|
||
)
|
||
assert (breaker.window_blocks, breaker.window_len) == (0, 20), (
|
||
f"окно {breaker.window_blocks}/{breaker.window_len}: infra идёт в ЗНАМЕНАТЕЛЬ "
|
||
"(как record_failure), а не в числитель"
|
||
)
|
||
assert breaker.consecutive_blocks == 0, (
|
||
f"consecutive_blocks={breaker.consecutive_blocks}: infra не двигает серию, "
|
||
"иначе на коротком снапшоте прогон рвал бы safety-net по тем же отказам"
|
||
)
|
||
|
||
|
||
def test_fourteen_platform_blocks_of_twenty_still_abort_by_ratio() -> None:
|
||
"""Контроль на противоположную ошибку: #3184 не меняется (14/20 = порог 0.7)."""
|
||
breaker = _breaker()
|
||
for _ in range(6):
|
||
breaker.record_success()
|
||
for _ in range(14):
|
||
breaker.record_block(BAN_KIND_PLATFORM)
|
||
|
||
assert breaker.abort_reason() == "ratio", (
|
||
f"abort_reason={breaker.abort_reason()!r} при 14 настоящих блоках из 20 — "
|
||
"критерий #3184 обязан сработать ровно на пороге"
|
||
)
|
||
assert breaker.window_blocks == 14, f"числитель {breaker.window_blocks}, ожидали 14"
|
||
|
||
|
||
def test_infra_does_not_pad_the_numerator_to_the_threshold() -> None:
|
||
"""13 platform + 7 infra = 0.65, а не 1.0: infra числитель не добивает."""
|
||
breaker = _breaker()
|
||
for _ in range(7):
|
||
breaker.record_block(BAN_KIND_INFRA)
|
||
for _ in range(13):
|
||
breaker.record_block(BAN_KIND_PLATFORM)
|
||
|
||
assert breaker.abort_reason() is None, (
|
||
f"abort_reason={breaker.abort_reason()!r}: 13/20 = 0.65 ниже порога 0.7, "
|
||
"прогон рвётся только если infra зачли блоками"
|
||
)
|
||
|
||
|
||
# ── бэкфилл: тот же вопрос сквозь задачу ──────────────────────────────────────
|
||
|
||
|
||
def _fake_avito_settings() -> MagicMock:
|
||
return MagicMock(
|
||
scraper_fetch_mode="browser",
|
||
avito_detail_backfill_use_curl=False,
|
||
detail_backfill_block_ratio_window=_WINDOW,
|
||
detail_backfill_block_ratio_threshold=_THRESHOLD,
|
||
browser_http_endpoint="http://browser:9000",
|
||
avito_detail_backfill_rotate_after_attempts=15,
|
||
avito_detail_backfill_rotate_on_ban_max=0,
|
||
avito_detail_backfill_rotate_on_ban_min_gap=10,
|
||
)
|
||
|
||
|
||
def _mock_avito_db(n: int) -> MagicMock:
|
||
snapshot = [
|
||
{
|
||
"id": i + 1,
|
||
"source_url": f"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_{i + 1}",
|
||
}
|
||
for i in range(n)
|
||
]
|
||
db = MagicMock()
|
||
sel = MagicMock()
|
||
sel.mappings.return_value.all.return_value = snapshot
|
||
db.execute.return_value = sel
|
||
return db
|
||
|
||
|
||
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()
|
||
instance.lease_id = 42
|
||
return MagicMock(return_value=instance)
|
||
|
||
|
||
async def _run_avito(
|
||
fetch_results: list[Any], *, snapshot_size: int
|
||
) -> tuple[AvitoDetailBackfillResult, MagicMock]:
|
||
runs = MagicMock()
|
||
db = _mock_avito_db(snapshot_size)
|
||
with (
|
||
patch(_A_SETTINGS, _fake_avito_settings()),
|
||
patch(_A_SESSION),
|
||
patch(_A_SCRAPER),
|
||
patch(_A_RUNS, runs),
|
||
patch(_A_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
|
||
patch(_A_FETCH, AsyncMock(side_effect=fetch_results)),
|
||
patch(_A_SAVE, return_value=True),
|
||
patch(_A_ROTATE_PROXY, AsyncMock()),
|
||
patch(_A_SLEEP, new_callable=AsyncMock),
|
||
):
|
||
counters = await run_avito_detail_backfill(
|
||
db,
|
||
run_id=3288,
|
||
params={
|
||
"batch_size": snapshot_size,
|
||
"budget_sec": 3600,
|
||
"max_consecutive_blocks": 10,
|
||
},
|
||
)
|
||
return counters, runs
|
||
|
||
|
||
def _assert_identity(c: AvitoDetailBackfillResult, *, expected_attempted: int) -> None:
|
||
outcomes = c.enriched + c.blocked + c.gone + c.failed
|
||
assert (c.attempted, outcomes) == (expected_attempted, expected_attempted), (
|
||
f"attempted={c.attempted} vs исходы={outcomes} "
|
||
f"(enriched={c.enriched} blocked={c.blocked} gone={c.gone} failed={c.failed}), "
|
||
f"ожидали {expected_attempted} — попытка без исхода (#3338)"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sidecar_run_walks_the_whole_snapshot_and_is_not_banned() -> None:
|
||
"""20 отказов сайдкара + 10 успехов: снапшот пройден целиком, обрыва нет."""
|
||
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
|
||
fetches += [MagicMock() for _ in range(10)]
|
||
|
||
counters, runs = await _run_avito(fetches, snapshot_size=30)
|
||
|
||
finished = runs.mark_backfill_finished.call_args
|
||
assert finished is not None, "прогон не финализирован mark_backfill_finished"
|
||
# Приёмка #3288 первой строкой: величина, по которой прогон 5425 и оборвался.
|
||
assert finished.args[2].get("abort_reason") is None, (
|
||
f"abort_reason={finished.args[2].get('abort_reason')!r} при 20 отказах сайдкара: "
|
||
"обрыва по доле быть не могло — площадка не отказала ни разу"
|
||
)
|
||
_assert_identity(counters, expected_attempted=30)
|
||
assert (counters.blocked, counters.enriched) == (20, 10), (
|
||
f"blocked={counters.blocked} enriched={counters.enriched}: прогон обязан дойти "
|
||
"до конца снапшота, отказ нашего тракта его не рвёт"
|
||
)
|
||
assert finished.kwargs["aborted_by_blocks"] is False, (
|
||
"aborted_by_blocks=True при нулевых отказах площадки — прогон уйдёт в 'banned'"
|
||
)
|
||
assert finished.kwargs["ban_kinds"] == {BAN_KIND_INFRA: 20}, (
|
||
f"ban_kinds={finished.kwargs['ban_kinds']}: перепись диагнозов не должна "
|
||
"теряться — она и решает статус"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_real_platform_bans_still_abort_by_ratio() -> None:
|
||
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
|
||
fetches: list[Any] = [MagicMock() for _ in range(6)]
|
||
fetches += [AvitoBlockedError("firewall/soft-block") for _ in range(14)]
|
||
|
||
counters, runs = await _run_avito(fetches, snapshot_size=30)
|
||
|
||
_assert_identity(counters, expected_attempted=20)
|
||
finished = runs.mark_backfill_finished.call_args
|
||
assert finished.args[2].get("abort_reason") == "ratio", (
|
||
f"counters={finished.args[2]}: 14 отказов площадки из 20 — ровно порог #3184"
|
||
)
|
||
assert finished.kwargs["aborted_by_blocks"] is True, "обрыв по доле обязан быть помечен"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_pool_stops_run_with_its_own_reason() -> None:
|
||
"""Пустой пул — не блок: прогон завершается 'нечем ходить', а не баном."""
|
||
wrapped = AvitoSidecarUnavailableError("Avito detail browser fetch failed for https://...")
|
||
wrapped.__cause__ = NoProxyAvailableError("avito")
|
||
|
||
counters, runs = await _run_avito([wrapped], snapshot_size=30)
|
||
|
||
_assert_identity(counters, expected_attempted=1)
|
||
assert (counters.blocked, counters.failed) == (0, 1), (
|
||
f"blocked={counters.blocked} failed={counters.failed}: к площадке не ходили — "
|
||
"это отказ нашей стороны, а не блок"
|
||
)
|
||
assert runs.mark_backfill_finished.call_args is None, (
|
||
"прогон с пустым пулом не должен идти через backfill-honest-status: у него своя причина"
|
||
)
|
||
failed = runs.mark_failed.call_args
|
||
assert failed is not None, "прогон не финализирован mark_failed"
|
||
assert failed.args[3]["no_proxy_stop"] == 1, f"counters={failed.args[3]}: нет no_proxy_stop"
|
||
assert "пул прокси пуст" in failed.args[2], f"причина={failed.args[2]!r}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_pool_recognised_by_cause_not_by_text() -> None:
|
||
"""#3272: подстрока «no proxy available» в тексте — не доказательство пустого пула."""
|
||
liar = AvitoBlockedError("firewall page: no proxy available for provider='avito'")
|
||
|
||
counters, runs = await _run_avito([liar, MagicMock()], snapshot_size=2)
|
||
|
||
_assert_identity(counters, expected_attempted=2)
|
||
assert (counters.blocked, counters.enriched) == (1, 1), (
|
||
f"blocked={counters.blocked} enriched={counters.enriched}: отказ площадки с "
|
||
"«no proxy available» в тексте остаётся блоком — цепочка причин пуста"
|
||
)
|
||
assert runs.mark_failed.call_args is None, (
|
||
"прогон остановлен как «пул пуст» по подстроке в тексте — ровно ошибка #3272"
|
||
)
|
||
|
||
|
||
# ── статус прогона: диагноз infra ≠ 'banned' ──────────────────────────────────
|
||
|
||
|
||
def _finish(ban_kinds: dict[str, int], *, enriched: int) -> tuple[MagicMock, ...]:
|
||
counters = {"attempted": 90, "enriched": enriched, "blocked": 48, "gone": 0, "failed": 1}
|
||
with (
|
||
patch.object(runs_svc, "mark_banned") as banned,
|
||
patch.object(runs_svc, "mark_failed") as failed,
|
||
patch.object(runs_svc, "mark_done") as done,
|
||
):
|
||
runs_svc.mark_backfill_finished(
|
||
MagicMock(),
|
||
5425,
|
||
counters,
|
||
source="avito_detail_backfill",
|
||
aborted_by_blocks=True,
|
||
ban_kinds=ban_kinds,
|
||
)
|
||
return banned, failed, done
|
||
|
||
|
||
def test_infra_dominant_run_is_not_marked_banned() -> None:
|
||
"""41 infra из 48 при 41 обогащённой карточке — прогон 'done', не 'banned'."""
|
||
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=41)
|
||
|
||
assert banned.call_count == 0, (
|
||
"прогон помечен 'banned' при доминирующем infra — запись утверждает про "
|
||
"площадку то, чего не было"
|
||
)
|
||
assert (failed.call_count, done.call_count) == (0, 1), (
|
||
f"mark_failed={failed.call_count} mark_done={done.call_count}: 41 карточка "
|
||
"обогащена, прогон работу сделал"
|
||
)
|
||
|
||
|
||
def test_pure_infra_run_with_result_is_not_banned() -> None:
|
||
"""Основной кейс без примеси: перепись целиком infra, карточки есть → не 'banned'."""
|
||
banned, failed, done = _finish({BAN_KIND_INFRA: 20}, enriched=10)
|
||
|
||
assert (banned.call_count, failed.call_count, done.call_count) == (0, 0, 1), (
|
||
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
|
||
f"mark_done={done.call_count}: 20 отказов сайдкара при 10 обогащённых — 'done'"
|
||
)
|
||
|
||
|
||
def test_infra_dominant_run_without_result_stays_banned() -> None:
|
||
"""Контроль на ОБРАТНУЮ ошибку: ноль результата — по-прежнему 'banned' (#2764/#3196).
|
||
|
||
Диагноз infra на нулевом прогоне неотличим от бана площадки, опознанного по 5xx
|
||
(yandex, #3196): статус остаётся 'banned', честность несёт ban_kind. Понижение
|
||
до 'failed' прятало бы настоящий бан под «нашу поломку» — падало 2 теста CI.
|
||
"""
|
||
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=0)
|
||
|
||
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
|
||
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
|
||
f"mark_done={done.call_count}: нулевой прогон обязан остаться 'banned'"
|
||
)
|
||
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_INFRA, (
|
||
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}: диагноз обязан доехать"
|
||
)
|
||
|
||
|
||
def test_run_without_census_stays_banned() -> None:
|
||
"""Источник видов не различает (ban_kinds пуст) → прежнее поведение: 'banned'."""
|
||
banned, failed, done = _finish({}, enriched=41)
|
||
|
||
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
|
||
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
|
||
f"mark_done={done.call_count}: без переписи диагноза нет — статус не меняем"
|
||
)
|
||
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_UNKNOWN, (
|
||
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'unknown'"
|
||
)
|
||
|
||
|
||
def test_platform_dominant_run_is_still_banned() -> None:
|
||
"""Контроль на противоположную ошибку: настоящий бан площадки — по-прежнему 'banned'."""
|
||
banned, failed, done = _finish({BAN_KIND_PLATFORM: 41, BAN_KIND_INFRA: 7}, enriched=41)
|
||
|
||
assert banned.call_count == 1, (
|
||
f"mark_banned={banned.call_count} (failed={failed.call_count} done={done.call_count}): "
|
||
"площадка отказала в 41 блоке из 48 — это ровно 'banned'"
|
||
)
|
||
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
|
||
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
|
||
)
|