gendesign/tradein-mvp/backend/tests/test_2674_avito_full_load_coverage.py
bot-backend 6d76328168
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 2m52s
fix(tradein/avito): верное объяснение границы окна и критерий приёмки по глубине (#2674)
Разбор ревью PR #2685. Выбор окна в 7 суток подтверждён непредвзятым замером по
одному прогону (run 2990, exhaustive 02.08, 2184 датированных строки): W=2 -> 147,
W=6 -> 446, W=7 -> 1275, W=12 -> 1278. Шестёрка теряет две трети семёрки, а 7..12 —
плато в +3 лота, то есть семёрка стоит на самой дешёвой его точке. Потеря окна 2
занижена мной в первом заходе: не 3.26x, а 8.7x.

Механизм объяснён неверно. Пик на возрасте ровно 7 — не недельный авто-подъём Авито,
а квантование нашего же парсера относительных дат: «неделю назад» -> ровно today-7,
«две недели назад» -> today-14, возрасты 8..13 по этому пути недостижимы. То самое
плато (3 лота из 2184) это и доказывает: при реальном подъёме полоса 8..13 была бы
заполнена. Вывод от этого только крепнет — шестёрка режет не по пику распределения,
а по границе квантования и теряет бакет «неделю назад» целиком, а внутри него
реальный возраст от 7 до 13 суток.

51% не воспроизводится: 1212 из 3714 датированных наблюдений — 32.6%. Пятьдесят один
получается только на знаменателе, урезанном возрастами 0-13.

Цена по запросам описана неверно и в опасную сторону. Рост не пропорционален лотам:
стоимость бакета — ceil(свежих/50) страниц с полом 1-2, при окне 7 на бакет выходит
~15-20 свежих (1275 на 77 бакетов), то есть меньше страницы. Большинство бакетов как
стояло на 1-2 страницах, так и останется. Верхняя граница честная и продом пережитая:
полный обход без отсечки — 6 ч 59 мин (run 295) и 2 ч 34 мин (run 2990).

Отсюда же переписан критерий приёмки: ждать «9-10 тысяч собранных лотов» нельзя, это
уведёт в ложный вывод. Прогон с окном 2 уже собирал 2804 лота, потому что первые
страницы всё равно полные — объём почти не сдвинется, сдвинется глубина. Считать надо
лоты с listing_date в полосе [D-7, D-3] и число страниц из лог-строки paginated=.

Впечатана мина на случай отката такта: ни миграция (GREATEST только расширяет), ни
планировщик (расширяет до такта, не сужает) окно не сузят, поэтому interval_days
7 -> 1 при окне 7 даст восьмикратный охват каждый день. Такт и окно менять вместе.

int(params.get("interval_days", 1)) падал на значении null в jsonb — соседний параметр
строкой выше обрабатывался через явную проверку на None, этот нет.
2026-08-06 04:09:05 +05:00

225 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#2674, Avito full load: окно ретроспективы под недельный такт + 503 сайдкара.
Две находки эпика, обе — продолжающаяся потеря данных, а не история.
1. Окно разошлось с тактом. `interval_days` (каденс, миграция 206) и
`incremental_days` (глубина обхода, миграция 129) — два независимых литерала,
которые обязаны совпадать. На проде было {interval_days: 7, incremental_days: 2}:
прогон видит listing_date в [D-2, D] = 3 суток из 7, дни D+1..D+4 не попадают
ни в один прогон. Чиним в двух местах — данными (миграция 215) и кодом
(scheduler расширяет окно до такта, чтобы расхождение не вернулось).
2. 503 сайдкара не ретраился ни разу. `_fetch_serp_html_browser` классифицировал
"browser unavailable (proxy may be down)" как soft-ban и отправлял в бюджет
IP-ротации, а ротация снята в #2616 (max_rot=0, `_rotate_ip()` всегда False) →
условие `rot_done < max_rot` ложно всегда. Бюджет коротких backoff-retry стоял
в `else:` и для soft-ban был структурно недостижим. Итог на проде: один блип
сайдкара съедал бакет, четыре подряд — весь прогон
(`_AVITO_SWEEP_MAX_CONSECUTIVE_BLOCKED = 4`).
"""
from __future__ import annotations
import re
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from scraper_kit.avito_exceptions import AvitoRateLimitedError
from scraper_kit.orchestration import scheduler as kit_sched
from scraper_kit.orchestration.scheduler import (
SchedulerContext,
_job_avito_full_load,
_job_avito_full_load_exhaustive,
)
from scraper_kit.providers.avito import serp as serp_module
from scraper_kit.providers.avito.serp import _AVITO_SIDECAR_TRANSIENT_RETRIES, AvitoScraper
from app.services.scraper_adapters import RealScraperConfig
_MIGRATION_215 = (
Path(__file__).resolve().parents[1]
/ "data"
/ "sql"
/ "215_avito_full_load_window_matches_cadence.sql"
)
def _ctx() -> SchedulerContext:
return SchedulerContext(
config=MagicMock(),
matcher=MagicMock(),
enrichment=MagicMock(),
session_factory=MagicMock(),
runs=MagicMock(),
)
async def _incremental_days_passed(params: dict) -> object:
"""Прогнать _job_avito_full_load с params и вернуть переданный incremental_days."""
with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run:
await _job_avito_full_load(MagicMock(), 1, params, _ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
return kwargs.get("incremental_days")
# ── 1. Окно ретроспективы не уже такта ────────────────────────────────────────
async def test_window_widened_to_cadence() -> None:
"""Прод-конфиг на момент находки: такт 7 суток, окно 2 → окно расширяется до 7.
Фальсификация: без правки в scheduler._job_avito_full_load сюда приезжает 2 —
ровно те «двое суток из семи», о которых говорит эпик.
"""
assert await _incremental_days_passed({"interval_days": 7, "incremental_days": 2}) == 7
async def test_window_wider_than_cadence_left_alone() -> None:
"""Окно ШИРЕ такта — осознанный запас оператора, не сужаем."""
assert await _incremental_days_passed({"interval_days": 3, "incremental_days": 10}) == 10
async def test_daily_cadence_keeps_window() -> None:
"""Back-compat: без interval_days такт = 1 сутки, окно 2 уже перекрывает его."""
assert await _incremental_days_passed({"incremental_days": 2}) == 2
async def test_null_interval_days_does_not_crash() -> None:
"""`"interval_days": null` в jsonb приезжает сюда как None, а int(None) — TypeError."""
assert await _incremental_days_passed({"interval_days": None, "incremental_days": 2}) == 2
async def test_no_window_stays_exhaustive() -> None:
"""Строка без incremental_days = полный обход; такт не должен её «инкрементализировать»."""
assert await _incremental_days_passed({"interval_days": 7}) is None
async def test_exhaustive_job_ignores_window_params() -> None:
"""Соседняя джоба всегда идёт полным обходом, что бы ни лежало в её params."""
with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run:
await _job_avito_full_load_exhaustive(
MagicMock(), 1, {"interval_days": 7, "incremental_days": 2}, _ctx()
)
_args, kwargs = mock_run.call_args
assert kwargs.get("incremental_days") is None
# ── 2. 503 сайдкара получает backoff-retry, а не мгновенный отказ ─────────────
def _sidecar_503() -> httpx.HTTPStatusError:
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
response = httpx.Response(
503,
json={"error": "browser unavailable (proxy may be down)"},
request=request,
)
return httpx.HTTPStatusError("503", request=request, response=response)
@pytest.mark.asyncio
async def test_sidecar_503_retried_before_giving_up() -> None:
"""503 «browser unavailable» — это упавший launch камуфокса, а не бан площадки.
Сайдкар на таком отказе сам поднимает фоновый retry launch'а, поэтому повтор
через пару секунд обычно проходит. Ждём 1 попытку + весь бюджет backoff-retry.
Фальсификация: без правки soft-ban уходит в ветку ротации (max_rot=0 → условие
ложно всегда), бюджет backoff недостижим → ровно 1 вызов fetch и немедленный
AvitoRateLimitedError.
"""
scraper = AvitoScraper(RealScraperConfig())
assert scraper._cffi is None
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(side_effect=_sidecar_503())
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
with pytest.raises(AvitoRateLimitedError):
await scraper._fetch_serp_html(
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
)
assert scraper._browser.fetch.await_count == 1 + _AVITO_SIDECAR_TRANSIENT_RETRIES
@pytest.mark.asyncio
async def test_sidecar_503_recovers_without_aborting_bucket() -> None:
"""Один блип сайдкара больше не стоит бакета: вторая попытка отдаёт HTML."""
scraper = AvitoScraper(RealScraperConfig())
scraper._browser = AsyncMock()
scraper._browser.fetch = AsyncMock(
side_effect=[_sidecar_503(), "<html><body>serp</body></html>"]
)
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
html = await scraper._fetch_serp_html(
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
)
assert html == "<html><body>serp</body></html>"
assert scraper._browser.fetch.await_count == 2
# ── 3. Миграция 215: статические инварианты ──────────────────────────────────
# Живой БД в тестах нет (см. conftest.py) — эффект UPDATE'а проверяется на проде,
# здесь фиксируем форму файла: транзакционность, отсутствие DDL, точное совпадение
# source (соседняя джоба с похожим именем ловится подстрокой), вывод значения из
# фактического interval_days вместо литерала.
def _sql() -> str:
return _MIGRATION_215.read_text(encoding="utf-8")
def _executable_sql() -> str:
"""SQL без построчных `--`-комментариев — только исполняемый код."""
lines = []
for raw in _sql().splitlines():
code = raw.split("--", 1)[0]
if code.strip():
lines.append(code)
return "\n".join(lines)
def _flat() -> str:
return re.sub(r"\s+", " ", _executable_sql()).strip().lower()
def test_migration_215_is_transactional_and_data_only() -> None:
flat = _flat()
assert flat.startswith("begin;")
assert flat.endswith("commit;")
for ddl in ("create ", "alter ", "drop ", "truncate "):
assert ddl not in flat, f"миграция только про данные, найден DDL: {ddl!r}"
def test_migration_215_targets_only_avito_full_load() -> None:
"""Точное равенство source, НЕ подстрока — 'avito_full_load_exhaustive' рядом."""
flat = _flat()
assert "where source = 'avito_full_load'" in flat
assert "avito_full_load_exhaustive" not in _executable_sql()
assert " like " not in flat
def test_migration_215_derives_window_from_cadence() -> None:
"""Окно выводится из interval_days строки, а не хардкодится числом."""
flat = _flat()
assert "interval_days" in flat
assert "greatest(" in flat
assert re.search(r"'incremental_days',\s*greatest", flat) is not None
def test_migration_215_merges_params_not_overwrites() -> None:
"""`||` мерджит ключ в default_params — соседние ключи обязаны выжить."""
flat = _flat()
assert "default_params ||" in flat or "default_params\n||" in _executable_sql().lower()
assert "set default_params = jsonb_build_object" not in flat
def test_migration_215_has_no_psycopg_cast_trap() -> None:
"""Repo-конвенция: CAST(x AS type), не x::type."""
assert "::" not in _executable_sql()
assert "cast(" in _flat()