All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m55s
Deploy Trade-In / build-backend (push) Successful in 1m47s
Deploy Trade-In / deploy (push) Successful in 3m28s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m42s
234 lines
9.8 KiB
Python
234 lines
9.8 KiB
Python
"""fix/domclick-incremental-save: большая выдача (Москва ≈23 690 лотов против ЕКБ
|
||
≈6 300) теряла ВСЁ собранное при снятии свипа по watchdog — сохранение было ОДНО,
|
||
в самом конце `run_domclick_city_sweep`. Прод run 7344 (`domclick_city_sweep_moskva`):
|
||
за 2ч watchdog'а (11 100с) пройдено 2 бакета из 6.
|
||
|
||
Три дефекта, три слоя тестов ниже:
|
||
1. Инкрементальное сохранение по бакетам (`fetch_city.on_bucket`) — секция 1.
|
||
2. Чекпоинт врал ("пройдено" без "сохранено") — секция 1, тест
|
||
`test_on_bucket_exception_interrupts_bucket_loop` документирует нюанс, на
|
||
котором строится фикс в pipeline.py: scraper.completed_buckets отмечает бакет
|
||
как "фетч прошёл" ДО вызова on_bucket, поэтому пайплайн больше не берёт
|
||
чекпоинт оттуда — только из факта успешного on_bucket (см. комментарий у
|
||
"Перенести счётчики" в run_domclick_city_sweep).
|
||
3. Watchdog не знал о размере выдачи — секция 2, `watchdog_sec` override.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
from unittest.mock import patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
import pytest
|
||
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
|
||
from scraper_kit.providers.domclick.serp import (
|
||
ROOM_BUCKETS,
|
||
DomClickBlockedError,
|
||
DomClickScraper,
|
||
)
|
||
|
||
PFX = "scraper_kit.orchestration.pipeline"
|
||
|
||
|
||
# ── Секция 1: DomClickScraper.fetch_city(on_bucket=...) ──────────────────────
|
||
|
||
|
||
def _scraper() -> DomClickScraper:
|
||
return DomClickScraper(SimpleNamespace(scraper_proxy_url=None))
|
||
|
||
|
||
class _FakeFetcherCtx:
|
||
async def __aenter__(self) -> SimpleNamespace:
|
||
# request_context_reset (#3118): вызывается ДО report_ban на QRATOR-блоке —
|
||
# двойник фетчера обязан его иметь, иначе AttributeError на первом же блоке.
|
||
return SimpleNamespace(
|
||
report_ban=lambda *_a, **_k: None,
|
||
request_context_reset=lambda *_a, **_k: None,
|
||
)
|
||
|
||
async def __aexit__(self, *_exc: Any) -> None:
|
||
return None
|
||
|
||
|
||
async def _run_fetch_city(
|
||
scraper: DomClickScraper,
|
||
*,
|
||
sweep_bucket: Any,
|
||
on_bucket: Any = None,
|
||
start: int = 0,
|
||
skip: set[str] | None = None,
|
||
) -> list[str]:
|
||
with (
|
||
patch.object(scraper, "_sweep_bucket", sweep_bucket),
|
||
patch(
|
||
"scraper_kit.providers._base.build_browser_fetcher",
|
||
lambda *_a, **_k: _FakeFetcherCtx(),
|
||
),
|
||
):
|
||
return await scraper.fetch_city(
|
||
city_id=1,
|
||
pages=1,
|
||
start_bucket_index=start,
|
||
skip_buckets=skip,
|
||
on_bucket=on_bucket,
|
||
)
|
||
|
||
|
||
async def test_on_bucket_called_once_per_bucket_with_only_its_own_lots() -> None:
|
||
"""on_bucket зовётся по разу на каждый успешный бакет с лотами ИМЕННО его,
|
||
а не накопленным out_lots (главная регрессия #1 issue — было "одно сохранение
|
||
в конце")."""
|
||
|
||
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||
out_lots.append(f"{rooms}-a")
|
||
out_lots.append(f"{rooms}-b")
|
||
|
||
calls: list[tuple[str, list[str]]] = []
|
||
s = _scraper()
|
||
result = await _run_fetch_city(
|
||
s, sweep_bucket=_fake_sweep, on_bucket=lambda n, lots: calls.append((n, list(lots)))
|
||
)
|
||
|
||
assert [c[0] for c in calls] == list(ROOM_BUCKETS)
|
||
for bucket, lots in calls:
|
||
assert lots == [f"{bucket}-a", f"{bucket}-b"], (bucket, lots, "получил чужие лоты")
|
||
assert len(result) == len(ROOM_BUCKETS) * 2
|
||
|
||
|
||
async def test_on_bucket_skips_failed_and_blocked_buckets() -> None:
|
||
"""Битый бакет (generic Exception) и бакет с QRATOR-блоком не отдают лоты в
|
||
on_bucket — там ничего не собрано/не гарантированно собрано."""
|
||
|
||
async def _fake_sweep_fail_middle(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||
out_lots.append(f"{rooms}-x")
|
||
if rooms == "1":
|
||
raise ValueError("boom")
|
||
|
||
calls_a: list[str] = []
|
||
s_a = _scraper()
|
||
await _run_fetch_city(
|
||
s_a, sweep_bucket=_fake_sweep_fail_middle, on_bucket=lambda n, _lots: calls_a.append(n)
|
||
)
|
||
assert "1" not in calls_a
|
||
# continue идёт дальше — остальные бакеты всё равно получают on_bucket.
|
||
assert calls_a == [b for b in ROOM_BUCKETS if b != "1"], calls_a
|
||
|
||
async def _fake_sweep_block(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||
out_lots.append(f"{rooms}-x")
|
||
if rooms == "1":
|
||
raise DomClickBlockedError("QRATOR")
|
||
|
||
calls_b: list[str] = []
|
||
s_b = _scraper()
|
||
await _run_fetch_city(
|
||
s_b, sweep_bucket=_fake_sweep_block, on_bucket=lambda n, _lots: calls_b.append(n)
|
||
)
|
||
# break останавливает обход целиком — после блока ни один бакет не пробуется.
|
||
assert calls_b == ["st"], calls_b
|
||
|
||
|
||
async def test_on_bucket_exception_interrupts_bucket_loop() -> None:
|
||
"""Исключение из on_bucket (канал кооперативной отмены) прерывает цикл по
|
||
ROOM_BUCKETS целиком — остальные бакеты не идут."""
|
||
|
||
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||
out_lots.append(f"{rooms}-x")
|
||
|
||
visited: list[str] = []
|
||
|
||
def _on_bucket(name: str, lots: list[str]) -> None:
|
||
visited.append(name)
|
||
if name == "1":
|
||
raise RuntimeError("cancelled")
|
||
|
||
s = _scraper()
|
||
with pytest.raises(RuntimeError, match="cancelled"):
|
||
await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=_on_bucket)
|
||
|
||
assert visited == ["st", "1"], visited
|
||
# Скрейпер-уровень успел зафетчить оба бакета ДО того, как on_bucket поднял
|
||
# исключение — на этом нюансе строится фикс чекпоинта в pipeline.py: pipeline
|
||
# больше не берёт done_buckets из scraper.completed_buckets, только из факта
|
||
# успешного on_bucket (см. run_domclick_city_sweep._on_bucket/_checkpoint).
|
||
assert s.completed_buckets == ["st", "1"], s.completed_buckets
|
||
|
||
|
||
async def test_on_bucket_none_preserves_return_value() -> None:
|
||
"""on_bucket=None → поведение прежнее, байт-в-байт: лоты возвращаются из
|
||
fetch_city как раньше, никаких промежуточных вызовов."""
|
||
|
||
async def _fake_sweep(*, rooms: str, out_lots: list[str], **_kw: Any) -> None:
|
||
out_lots.append(f"{rooms}-only")
|
||
|
||
s = _scraper()
|
||
result = await _run_fetch_city(s, sweep_bucket=_fake_sweep, on_bucket=None)
|
||
|
||
assert result == [f"{b}-only" for b in ROOM_BUCKETS]
|
||
assert s.completed_buckets == list(ROOM_BUCKETS)
|
||
|
||
|
||
# ── Секция 2: run_domclick_city_sweep(watchdog_sec=...) ──────────────────────
|
||
|
||
|
||
class _NoOpRuns:
|
||
"""Достаточно методов, чтобы SERP-фаза дошла до asyncio.wait_for и честно
|
||
финализировалась после симулированного TimeoutError — значения не важны,
|
||
важен ТОЛЬКО timeout, с которым позвали wait_for."""
|
||
|
||
def is_cancelled(self, db: Any, run_id: int) -> bool:
|
||
return False
|
||
|
||
def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||
return None
|
||
|
||
def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None:
|
||
return None
|
||
|
||
def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None:
|
||
return None
|
||
|
||
def mark_banned(
|
||
self, db: Any, run_id: int, error: str, counters: dict[str, Any], **kw: Any
|
||
) -> None:
|
||
return None
|
||
|
||
|
||
async def _drive_and_capture_timeout(**kwargs: Any) -> float:
|
||
captured: dict[str, float] = {}
|
||
|
||
async def _fake_wait_for(coro: Any, timeout: float) -> None:
|
||
captured["timeout"] = timeout
|
||
coro.close()
|
||
raise TimeoutError()
|
||
|
||
with (
|
||
patch(f"{PFX}.asyncio.wait_for", _fake_wait_for),
|
||
patch(f"{PFX}.runs", _NoOpRuns()),
|
||
):
|
||
await run_domclick_city_sweep(
|
||
object(), # type: ignore[arg-type]
|
||
config=SimpleNamespace(browser_http_endpoint="http://x:9000"),
|
||
matcher=object(),
|
||
run_id=1,
|
||
city_id=4,
|
||
**kwargs,
|
||
)
|
||
return captured["timeout"]
|
||
|
||
|
||
async def test_watchdog_sec_default_formula_matches_prod_11100() -> None:
|
||
"""watchdog_sec=None (дефолт) → прежняя формула байт-в-байт. pages=100,
|
||
delay=6.0 — те же параметры, что дали 11 100с в run 7344 (Москва)."""
|
||
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0)
|
||
assert timeout == 11100
|
||
|
||
|
||
async def test_watchdog_sec_override_bypasses_formula() -> None:
|
||
"""watchdog_sec задан явно → формула не считается вовсе, идёт ровно override
|
||
— даже с теми же pages/delay, что в тесте формулы выше."""
|
||
timeout = await _drive_and_capture_timeout(pages=100, request_delay_sec=6.0, watchdog_sec=777)
|
||
assert timeout == 777
|