All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 5m8s
proxy_rotate_attempts / proxy_rotate_attempt_timeout_s тюнили ретраи changeip-GET. Сам changeip снят в #2616 шаг 2 (аккаунт mobileproxy закрыт, ссылки нет), и с тех пор ручки живут пламбингом: Settings -> property адаптера -> поле протокола ScraperConfig -> и всё. Ни одного потребителя, только четыре теста, которые заполняют их при сборке конфига. Комментарий над ними в contracts.py оправдывал их сохранение так: «оставлены как budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for» — но wait_for там берёт СОСЕДНЕЕ поле, avito_proxy_rotate_settle_s (avito_detail_backfill.py:249). То есть комментарий приписывал этим двум полям работу третьего и тем самым прикрывал их мёртвость. Соседние ручки проверены и ОСТАВЛЕНЫ, они действительно читаются: * avito/cian/yandex_proxy_max_rotations — pipeline._max_rotations; * avito_proxy_rotate_settle_s — asyncio.wait_for в avito_detail_backfill. Заодно сжат комментарий в config.py: перечисление истории changeip заменено на то, что нужно знать сейчас — кто читает оставшиеся две ручки и где живая ротация (ASOCKS_API_TOKEN / proxy_rotation, #2611). ruff clean, 4974 passed / 37 skipped.
293 lines
14 KiB
Python
293 lines
14 KiB
Python
"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона.
|
||
|
||
#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket
|
||
heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), —
|
||
но единственным входом оставил админку. У avito full-load админского эндпоинта нет
|
||
вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799
|
||
на origin/main). То есть боевой путь возобновления не существовал ни одного дня.
|
||
|
||
Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных
|
||
прогонах с ЖИВОЙ незабранной точкой — avito_full_load 242, cian_full_load 134,
|
||
avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из
|
||
84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08.
|
||
|
||
Красный прогон на origin/main:
|
||
1. `test_scheduler_hands_checkpoint_to_pipeline` — планировщик отдаёт в пайплайн
|
||
resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках);
|
||
2. `test_partial_bucket_is_not_complete` — бакет с выпавшей страницей приезжает в
|
||
on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще);
|
||
3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` — TypeError: `_on_bucket`
|
||
на main принимает два аргумента, признаку полноты некуда приехать.
|
||
Тесты ладдера (`_resume_decision`) на main падают с AttributeError — функции нет.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
from scraper_kit.orchestration import scheduler as sched
|
||
from scraper_kit.orchestration.pipeline import run_avito_full_load
|
||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||
|
||
PFX = "scraper_kit.orchestration.pipeline"
|
||
|
||
# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона
|
||
# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание».
|
||
_PARAMS = {
|
||
"concurrency": 1,
|
||
"interval_days": 7,
|
||
"secondary_only": True,
|
||
"request_delay_sec": 7.0,
|
||
"price_cap_per_bucket": 1400,
|
||
}
|
||
|
||
|
||
def _candidate(**over: Any) -> SimpleNamespace:
|
||
"""Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде."""
|
||
base = {
|
||
"prev_id": 3547,
|
||
"prev_status": "cancelled",
|
||
"prev_counters": {
|
||
"unique_fetched": 5496,
|
||
"done_buckets": [f"room_1_komn:{i}:0" for i in range(35)],
|
||
},
|
||
"same_params": True,
|
||
"age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08
|
||
"interval_days": "7",
|
||
}
|
||
base.update(over)
|
||
return SimpleNamespace(**base)
|
||
|
||
|
||
class _FakeDb:
|
||
"""Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE."""
|
||
|
||
def __init__(self, row: Any) -> None:
|
||
self.row = row
|
||
self.written: list[dict[str, Any]] = []
|
||
|
||
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||
if params and "counters" in params: # update_heartbeat пишет вердикт
|
||
self.written.append(params)
|
||
return MagicMock()
|
||
return MagicMock(fetchone=lambda: self.row)
|
||
|
||
def commit(self) -> None:
|
||
pass
|
||
|
||
|
||
# ── 1. Главное: планировщик обязан отдать точку в пайплайн ───────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("job", "pipeline_fn"),
|
||
[
|
||
(sched._job_avito_full_load, "run_avito_full_load"),
|
||
(sched._job_avito_full_load_exhaustive, "run_avito_full_load"),
|
||
(sched._job_cian_full_load, "run_cian_full_load"),
|
||
],
|
||
)
|
||
async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None:
|
||
"""Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля.
|
||
|
||
Падает на origin/main: планировщик передаёт литеральный None — 433 корзины за 90
|
||
суток перебирались заново, включая 35 корзин прогона 3547.
|
||
"""
|
||
db = _FakeDb(_candidate())
|
||
captured: dict[str, Any] = {}
|
||
|
||
async def _spy(*_a: Any, **kw: Any) -> None:
|
||
captured.update(kw)
|
||
|
||
with patch.object(sched, pipeline_fn, _spy):
|
||
await job(db, 4000, dict(_PARAMS), MagicMock())
|
||
|
||
assert captured["resume_run_id"] == 3547
|
||
|
||
|
||
async def test_verdict_lands_in_counters_of_new_run() -> None:
|
||
"""Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах.
|
||
|
||
Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому
|
||
молчаливый отказ подхватить неотличим от отсутствия правки.
|
||
"""
|
||
db = _FakeDb(_candidate(prev_status="zombie"))
|
||
with patch.object(sched, "run_avito_full_load", AsyncMock()):
|
||
await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock())
|
||
|
||
assert db.written, "вердикт о подхвате не записан в counters нового прогона"
|
||
written = db.written[-1]["counters"]
|
||
assert '"resume_reason": "status_zombie"' in written
|
||
assert '"resume_candidate": 3547' in written
|
||
|
||
|
||
# ── 2. Ладдер отказов: у каждого нуля своя причина ───────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("row", "reason"),
|
||
[
|
||
(None, "no_prev_run"),
|
||
(_candidate(prev_status="done"), "status_done"),
|
||
(_candidate(prev_status="zombie"), "status_zombie"),
|
||
(_candidate(same_params=False), "params_changed"),
|
||
(_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"),
|
||
(_candidate(age_h=200.0), "checkpoint_stale"),
|
||
(_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"),
|
||
],
|
||
)
|
||
def test_resume_refusals_are_named(row: Any, reason: str) -> None:
|
||
"""«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы."""
|
||
resume_id, verdict = sched._resume_decision(row)
|
||
assert resume_id is None
|
||
assert verdict["resume_reason"] == reason
|
||
assert verdict["resume_from"] is None
|
||
|
||
|
||
def test_resume_chain_is_bounded() -> None:
|
||
"""Цепочка возобновлений считается и упирается в потолок, а не тянется вечно.
|
||
|
||
Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход
|
||
обязан начаться раньше, чем сводка объявит источник просроченным.
|
||
"""
|
||
assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1
|
||
_id, first = sched._resume_decision(_candidate())
|
||
assert first["resume_chain"] == 1
|
||
_id2, second = sched._resume_decision(
|
||
_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1})
|
||
)
|
||
assert second["resume_chain"] == sched._MAX_RESUME_CHAIN
|
||
third_id, third = sched._resume_decision(
|
||
_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2})
|
||
)
|
||
assert third_id is None and third["resume_reason"] == "chain_limit"
|
||
|
||
|
||
def test_stale_threshold_follows_the_source_tick() -> None:
|
||
"""Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой.
|
||
|
||
cian ходит раз в 3 суток, avito — раз в 7; одна и та же точка возрастом 100 ч для
|
||
первого просрочена, для второго свежая. Плюс сутки — сетка запуска (см.
|
||
_resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе
|
||
точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) — нет.
|
||
"""
|
||
assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None
|
||
assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547
|
||
assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547
|
||
assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None
|
||
|
||
|
||
# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ─────────────────
|
||
|
||
|
||
def _serp_config() -> SimpleNamespace:
|
||
return SimpleNamespace(
|
||
scraper_fetch_mode="curl_cffi",
|
||
browser_http_endpoint="http://browser.test/fetch",
|
||
scraper_proxy_url=None,
|
||
avito_proxy_max_rotations=0,
|
||
avito_serp_ok_not_banned=True,
|
||
avito_proxy_rotate_settle_s=0.0,
|
||
scraper_skip_seen_today=False,
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("page2_html", "expected_complete"),
|
||
[(None, False), ("<page2/>", True)],
|
||
)
|
||
async def test_partial_bucket_is_not_complete(
|
||
page2_html: str | None, expected_complete: bool
|
||
) -> None:
|
||
"""Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан».
|
||
|
||
Контрольная половина обязательна: реализация «всегда False» тоже прошла бы
|
||
одностороннюю проверку, но убила бы возобновление целиком.
|
||
|
||
Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты
|
||
в протоколе нет — частичный бакет неотличим от целого и попадает в done_buckets.
|
||
"""
|
||
scraper = AvitoScraper(_serp_config())
|
||
scraper.request_delay_sec = 0.0
|
||
calls: list[tuple[str, bool]] = []
|
||
|
||
def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg]
|
||
calls.append((key, complete))
|
||
|
||
async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None:
|
||
return page2_html if page == 2 else f"<page{page}/>"
|
||
|
||
with (
|
||
patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page),
|
||
patch.object(
|
||
AvitoScraper,
|
||
"_parse_html",
|
||
lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")],
|
||
),
|
||
):
|
||
await scraper._paginate_leaf_bucket(
|
||
room_slug="kvartiry_1_komnatnye",
|
||
room_label="room_1_komn",
|
||
lo=0,
|
||
hi=3999999,
|
||
html="<page1/>",
|
||
max_pages=3,
|
||
seen={},
|
||
price_cap_per_bucket=1400,
|
||
max_pages_per_bucket=100,
|
||
concurrency=2,
|
||
secondary_only=False,
|
||
on_bucket=_on_bucket,
|
||
skip_buckets=None,
|
||
expected_total=3 * 50,
|
||
)
|
||
|
||
assert [c[1] for c in calls] == [expected_complete]
|
||
|
||
|
||
async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None:
|
||
"""Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал.
|
||
|
||
Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить
|
||
частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда.
|
||
"""
|
||
finals: list[dict[str, Any]] = []
|
||
|
||
class _Recorder:
|
||
def is_cancelled(self, *_a: Any, **_k: Any) -> bool:
|
||
return False
|
||
|
||
def update_heartbeat(self, *_a: Any, **_k: Any) -> None:
|
||
pass
|
||
|
||
def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None:
|
||
finals.append(dict(counters))
|
||
|
||
async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None:
|
||
on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True)
|
||
on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False)
|
||
|
||
scraper = MagicMock()
|
||
scraper.__aenter__ = AsyncMock(return_value=scraper)
|
||
scraper.__aexit__ = AsyncMock(return_value=None)
|
||
scraper.fetch_all_secondary = _fetch
|
||
|
||
with (
|
||
patch(f"{PFX}.AvitoScraper", return_value=scraper),
|
||
patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))),
|
||
patch(f"{PFX}.runs", _Recorder()),
|
||
):
|
||
counters = await run_avito_full_load(
|
||
MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock()
|
||
)
|
||
|
||
assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"]
|
||
assert finals[0]["partial_buckets"] == 1
|
||
assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены"
|