fix(tradein): yandex combos watchdog timeout P0 + deploy filter gap
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / changes (pull_request) Successful in 6s
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

Fix A: scale asyncio.wait_for timeout in run_yandex_city_sweep for
combos/center mode. Old 240s budget was sized for one geo anchor;
with 30 combos × 3 pages × ~21s each = ~1890s+ of work, sweep
timed out at 240s producing lots_fetched=0. Now computes:
  sweep_timeout = max(240, num_combos * max_pages * (delay + 12) + 300)
For default 30×3×(9+12)+300 ≈ 2190s. Explicit-anchor mode keeps 240s.

Fix B: add scrape_pipeline.py to the `scraper` path filter in
deploy-tradein.yml so changes to sweep logic trigger scraper container
recreate. Previously only scrapers/** and scheduler.py were covered,
meaning #1658 deploy didn't recreate the scraper container.

Tests: 2 new unit tests for timeout computation (pure arithmetic, no
sleep) + explicit-anchor mode regression (wait_for called with 240s).
Full suite: 1844 passed, 2 deselected.
This commit is contained in:
bot-backend 2026-06-16 23:18:32 +03:00
parent e7901bc1e8
commit ed4662e37b
3 changed files with 130 additions and 4 deletions

View file

@ -47,6 +47,7 @@ jobs:
- '.forgejo/workflows/deploy-tradein.yml'
scraper:
- 'tradein-mvp/backend/app/services/scrapers/**'
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
- 'tradein-mvp/backend/app/services/scheduler.py'
- 'tradein-mvp/backend/app/scheduler_main.py'
- 'tradein-mvp/backend/app/tasks/**'

View file

@ -56,6 +56,15 @@ logger = logging.getLogger(__name__)
# Диапазон 180-300 с; 240 — разумный середина (4 мин > любой нормальный anchor).
ANCHOR_TIMEOUT_SEC: int = 240
# Константы для расчёта watchdog-таймаута combos-режима Yandex sweep.
# В combos-режиме единственный "anchor" делает num_combos × max_pages fetch'ей;
# каждый fetch занимает request_delay_sec + сетевой overhead. ADDRESS_ENRICH_BUDGET_S
# добавляет буфер на address-enrich фазу (per-listing HTTP + sleep × N листингов).
# Пример: 30 combos × 3 pages × (9+12) + 300 ≈ 2190s (~37 мин) — надёжно укладывается
# в окно 02:00-05:00 без риска перекрытия следующего sweep'а.
_YANDEX_COMBOS_PER_FETCH_S: float = 12.0 # network + parse margin (секунды на страницу)
_YANDEX_ADDRESS_ENRICH_BUDGET_S: float = 300.0 # бюджет на address-enrich фазу
# Default anchors ЕКБ — 5 точек покрытия города
EKB_ANCHORS: list[tuple[float, float, str]] = [
(56.8400, 60.6050, "Центр"),
@ -760,8 +769,26 @@ async def run_yandex_city_sweep(
counters = YandexCitySweepCounters(anchors_total=len(_anchors))
inter_anchor_delay = request_delay_sec if request_delay_sec is not None else 7.0
enrich_delay = request_delay_sec if request_delay_sec is not None else 3.0
_resolved_delay = request_delay_sec if request_delay_sec is not None else 9.0
consecutive_failures = 0
# Вычисляем watchdog-таймаут для combos-режима (центр, anchors=None).
# В combos-режиме один "anchor" выполняет num_combos × max_pages fetch'ей —
# каждый занимает примерно _resolved_delay + _YANDEX_COMBOS_PER_FETCH_S секунд.
# Для 30 combos × 3 pages × (9+12) + 300 ≈ 2190s (~37 мин).
# В explicit-anchor (тест/override) режиме оставляем ANCHOR_TIMEOUT_SEC (240s) —
# там каждый anchor небольшой и watchdog работает как старый защитный барьер.
_num_combos = len(_rooms_list) * len(_price_ranges)
if anchors is None and _num_combos > 0:
_sweep_timeout = max(
ANCHOR_TIMEOUT_SEC,
_num_combos * pages_per_anchor * (_resolved_delay + _YANDEX_COMBOS_PER_FETCH_S)
+ _YANDEX_ADDRESS_ENRICH_BUDGET_S,
)
else:
# Explicit anchors (backward-compat тесты или ручной override) — legacy timeout.
_sweep_timeout = ANCHOR_TIMEOUT_SEC
# Прокси для address-enrich curl_cffi сессии (зеркало yandex_address_backfill).
_proxy_url = settings.scraper_proxy_url
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
@ -954,18 +981,18 @@ async def run_yandex_city_sweep(
)
try:
await asyncio.wait_for(_yandex_anchor_phases(), timeout=ANCHOR_TIMEOUT_SEC)
await asyncio.wait_for(_yandex_anchor_phases(), timeout=_sweep_timeout)
except TimeoutError:
logger.warning(
"yandex-sweep run_id=%d: anchor #%d/%d (%s, %.4f, %.4f) "
"timed out after %ds — skipping",
"timed out after %.0fs — skipping",
run_id,
idx,
len(_anchors),
name,
lat,
lon,
ANCHOR_TIMEOUT_SEC,
_sweep_timeout,
)
counters.errors_count += 1
consecutive_failures += 1
@ -987,7 +1014,7 @@ async def run_yandex_city_sweep(
db,
run_id,
f"yandex sweep aborted: {consecutive_failures} consecutive "
f"anchor failures (last: anchor timeout {ANCHOR_TIMEOUT_SEC}s)",
f"anchor failures (last: anchor timeout {_sweep_timeout:.0f}s)",
counters.to_dict(),
)
return counters

View file

@ -797,3 +797,101 @@ async def test_explicit_anchors_still_works(
assert c["price_ranges"] is not None
assert fake.done is not None
# ── Watchdog timeout: combos vs explicit-anchor mode ───────────────────────
def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None:
"""В combos/center mode (anchors=None) _sweep_timeout намного > ANCHOR_TIMEOUT_SEC.
Проверяет что вычисленный таймаут для дефолтных 30 combos × 3 pages > 1500s,
т.е. не равен ANCHOR_TIMEOUT_SEC=240 и обеспечивает полный прогон sweep'а.
Не нужны мокапы только публичные константы и арифметика из модуля.
"""
from app.services.scrape_pipeline import (
_YANDEX_ADDRESS_ENRICH_BUDGET_S,
_YANDEX_COMBOS_PER_FETCH_S,
ANCHOR_TIMEOUT_SEC,
)
from app.services.scrapers.yandex_realty import DEFAULT_PRICE_RANGES, ROOM_PATH
# Параметры prod-режима
rooms_list = list(ROOM_PATH.keys()) # 5 комнатностей
price_ranges = DEFAULT_PRICE_RANGES # 6 ценовых диапазонов
max_pages = 3
request_delay_sec = 9.0 # продакшн default
num_combos = len(rooms_list) * len(price_ranges)
assert num_combos == 30, f"Expected 30 combos, got {num_combos}"
sweep_timeout = max(
ANCHOR_TIMEOUT_SEC,
num_combos * max_pages * (request_delay_sec + _YANDEX_COMBOS_PER_FETCH_S)
+ _YANDEX_ADDRESS_ENRICH_BUDGET_S,
)
# Должен быть существенно > 1500s (≈ 25 мин), что покрывает полный run.
assert sweep_timeout > 1500, (
f"sweep_timeout={sweep_timeout:.0f}s unexpectedly small — "
f"combos-mode will still timeout mid-sweep"
)
# И намного > ANCHOR_TIMEOUT_SEC (240s)
assert (
sweep_timeout > ANCHOR_TIMEOUT_SEC * 4
), f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s"
async def test_explicit_anchor_mode_uses_anchor_timeout_sec(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""anchors=[...] (explicit override) → wait_for используется с ANCHOR_TIMEOUT_SEC.
Проверяет что в legacy/test режиме (explicit anchors) sweep_timeout не раздут
до combos-значения он равен ANCHOR_TIMEOUT_SEC, т.к. каждый anchor маленький.
"""
import asyncio as _asyncio
from app.services.scrape_pipeline import ANCHOR_TIMEOUT_SEC
captured_timeouts: list[float] = []
_orig_wait_for = _asyncio.wait_for
async def _capturing_wait_for(coro, *, timeout): # type: ignore[no-untyped-def]
captured_timeouts.append(timeout)
# На самом деле запустить — мокнутый scraper вернёт [] мгновенно.
return await _orig_wait_for(coro, timeout=timeout)
monkeypatch.setattr(_asyncio, "wait_for", _capturing_wait_for)
async def fake_fetch_multi(
self: YandexRealtyScraper,
lat: float,
lon: float,
radius_m: int = 1000,
max_pages: int = 2,
**_: object,
) -> list[ScrapedLot]:
return []
monkeypatch.setattr(YandexRealtyScraper, "fetch_around_multi_room", fake_fetch_multi)
monkeypatch.setattr(scrape_pipeline, "save_listings", _fake_save)
fake = _FakeRuns()
_install_runs(monkeypatch, fake)
await scrape_pipeline.run_yandex_city_sweep(
db=MagicMock(),
run_id=20,
anchors=TEST_ANCHORS[:2], # explicit anchors — legacy mode
pages_per_anchor=2,
enrich_address=False,
request_delay_sec=0.0,
)
# В explicit-anchor режиме каждый anchor должен использовать ANCHOR_TIMEOUT_SEC
assert len(captured_timeouts) == 2
for t in captured_timeouts:
assert (
t == ANCHOR_TIMEOUT_SEC
), f"Expected ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s, got {t}s"