gendesign/tradein-mvp/backend/tests/test_2670_stale_source_digest.py
bot-backend 3a0b94e052
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
fix(scheduler): мерить свежесть источника данными, а не статусом прогона
`_STALE_SOURCES_SQL` считала свежесть возрастом последнего прогона со
статусом 'done'. Прогон с блоком честно финализируется как 'banned'
(#2657) и при этом вставляет строки: у domclick_city_sweep 886 строк
25.08 и 128 строк 23.08 — оба 'banned'. Источник, регулярно ловящий блок
и столь же регулярно приносящий данные, числился мёртвым навсегда,
отсюда ложный P1 #3118 «домклик не собирается с 5 августа».

Запрос отдаёт завершённые прогоны, решение «прогон дал данные»
принимает `run_brought_data` ТЕМ ЖЕ результатным словарём, которым уже
судит сторож нулевого результата (runs._RESULT_COUNTER_KEYS, #2703) —
одна мера на оба механизма. Не 'lots_inserted': это новизна, а не
наличие данных (здоровый дедуплицированный sweep вставляет ноль).
Результат не измерен (28 источников без результатного ключа) → судим
прежней мерой, статусом: «не измерено» ≠ «ноль». never_ok считается той
же мерой, иначе соврал бы в другую сторону.

Closes #3172
2026-09-06 00:02:21 +05:00

219 lines
12 KiB
Python
Raw 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.

"""#2670 (остаток): лестница напоминаний не отвечает на вопрос «что сломано сейчас».
#2720 вылечил «алерт ровно один раз за серию»: теперь вехи 3, 6, 12, 24, 48… Но лестница
шагает по ПОДРЯД ИДУЩИМ завершённым failed/banned прогонам, а на проде 2026-08-10 три
самых залежавшихся источника из шести просроченных ей недоступны — и лишь один из трёх
из-за редких вех:
┌────────────────────────────┬────────┬───────┬──────────────────────────────────────┐
│ источник │ стрик │ сут. │ когда напомнит лестница │
├────────────────────────────┼────────┼───────┼──────────────────────────────────────┤
│ avito_full_load_exhaustive │ 0 │ 49.5 │ никогда: 5 банов обнулил 'cancelled'
│ cian_history_backfill │ 0 │ 42.1 │ никогда: прогонов нет с 30.06 │
│ avito_full_load │ 31 │ 37.7 │ веха 48 → +17 прогонов × 7 сут = 119 │
│ avito_detail_backfill │ 5 │ 5.2 │ веха 6 → завтра │
│ domclick_city_sweep │ 5 │ 5.1 │ веха 6 → завтра │
│ domclick_detail_backfill │ 4 │ 5.0 │ веха 6 → послезавтра │
└────────────────────────────┴────────┴───────┴──────────────────────────────────────┘
Уплотнение вех (3,4,5,6…) чинит ТОЛЬКО третью строку: у первых двух стрик равен нулю,
уплотнять нечего — «замолчал» там означает «перестал производить прогоны», а не «серия
длиннее последней вехи». Поэтому остаток задачи закрывает сводка, считающая КАЛЕНДАРНЫЙ
возраст последнего успеха, а не длину серии.
Фальсификация: на коде до этой правки `emit_stale_digest`/`stale_sources` не существует
(ImportError на сборе тестов) — сводки нет ни в каком виде. Тест
`test_ladder_is_silent_for_the_worst_two` — КОНТРОЛЬ: он зелёный и до, и после правки и
показывает ровно то, чего сводка не заменяет, а добавляет: лестница на этих двух молчит.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.orchestration import runs as kit_runs
from scraper_kit.orchestration import scheduler as sched
NOW = datetime(2026, 8, 10, 8, 0, tzinfo=UTC)
def _row(source: str, interval_days: Any, age_days: float, never_ok: bool = False) -> Any:
"""Строка `_STALE_SOURCES_SQL`: last_ok уже схлопнут в `since` через COALESCE."""
return SimpleNamespace(
source=source,
interval_days=interval_days,
since=NOW - timedelta(days=age_days),
never_ok=never_ok,
)
# Снимок прода 2026-08-10 08:00 UTC: все 52 включённых расписания не влезают, взяты все
# просроченные + четыре контрольных, каждое из которых мимо порога по своей причине.
PROD_ROWS = [
_row("cian_history_backfill", None, 42.1), # такт по умолчанию (daily)
_row("avito_full_load_exhaustive", 7, 49.5),
_row("avito_full_load", 7, 37.7),
_row("avito_detail_backfill", None, 5.2),
_row("domclick_city_sweep", None, 5.1),
_row("domclick_detail_backfill", None, 5.0),
# ── контроль: НЕ просрочены ──
_row("rosreestr_quarter_poll", 28, 24.0), # 24 сут при такте 28 — норма
_row("sber_index_pull", 7, 4.1),
_row("avito_city_sweep", None, 1.1),
_row("proxy_healthcheck", None, 0.02),
]
# Порядок — по числу ПРОПУЩЕННЫХ ТАКТОВ (age/interval), а не по календарю: 42 суток
# у суточного backfill'а = 42 пропущенных такта, 49.5 у недельного = 7.
PROD_STALE = [
"cian_history_backfill", # 42.1 / 1
"avito_full_load_exhaustive", # 49.5 / 7 = 7.07
"avito_full_load", # 37.7 / 7 = 5.39
"avito_detail_backfill", # 5.2 / 1
"domclick_city_sweep", # 5.1 / 1
"domclick_detail_backfill", # 5.0 / 1
]
def _run_row(row: Any) -> Any:
"""Та же правда в кодировке #3172: строка запроса — ПРОГОН, а не готовая свёртка.
Свежесть теперь меряется последним прогоном, ПРИНЁСШИМ ДАННЫЕ (`freshness_rows`),
поэтому запрос отдаёт сырые поля прогона; порог, который проверяет этот файл, от
правки не зависит — он считает те же `since`/`never_ok`, просто свёрнутые в Python.
"""
return SimpleNamespace(
source=row.source,
interval_days=row.interval_days,
created_at=NOW - timedelta(days=400),
finished_at=row.since,
status="done",
counters={"lots_fetched": 1},
)
PROD_RUN_ROWS = [_run_row(r) for r in PROD_ROWS]
@pytest.fixture(autouse=True)
def _reset_digest_clock() -> Any:
"""Выпуск сводки помнится в памяти модуля — сбрасываем между тестами."""
sched._last_stale_digest_at = None
yield
sched._last_stale_digest_at = None
def _db(rows: list[Any]) -> MagicMock:
db = MagicMock()
db.execute.return_value.fetchall.return_value = rows
return db
# ── 1. Чистая логика порога ──────────────────────────────────────────────────
def test_stale_sources_names_exactly_the_prod_six() -> None:
"""Шесть просроченных из десяти, порядок — по числу пропущенных ТАКТОВ, не суток."""
stale = sched.stale_sources(PROD_ROWS, NOW)
assert [s.source for s in stale] == PROD_STALE
def test_quarterly_source_is_not_stale_at_24_days() -> None:
"""Порог считается в тактах: 24 сут для 28-суточного poll'а — не просрочка."""
assert sched.stale_sources([_row("rosreestr_quarter_poll", 28, 24.0)], NOW) == []
# …а 85 суток (>3×28) — уже просрочка.
assert [s.source for s in sched.stale_sources([_row("q", 28, 85.0)], NOW)] == ["q"]
@pytest.mark.parametrize("raw", [None, "null", "", "abc", 0, -5])
def test_broken_interval_falls_back_to_daily(raw: Any) -> None:
"""`interval_days: null` и мусор → такт 1 сут, как у compute_next_run_at."""
assert sched._schedule_interval_days(raw) == 1
def test_never_successful_source_is_reported_with_a_flag() -> None:
"""Расписание без единого 'done' считается от created_at и помечается явно."""
(only,) = sched.stale_sources([_row("brand_new", 1, 9.0, never_ok=True)], NOW)
assert only.never_ok is True
# ── 2. Выпуск сводки ─────────────────────────────────────────────────────────
def test_digest_emits_one_event_listing_all_stale_sources() -> None:
sentry = MagicMock()
with patch.object(sched, "sentry_sdk", sentry):
stale = sched.emit_stale_digest(_db(PROD_RUN_ROWS), now=NOW)
assert [s.source for s in stale] == PROD_STALE
sentry.capture_message.assert_called_once()
msg = sentry.capture_message.call_args[0][0]
assert msg.startswith("6 scraper sources are stale")
for name in PROD_STALE:
assert name in msg
assert "rosreestr_quarter_poll" not in msg
def test_digest_covers_the_two_sources_the_ladder_cannot_reach() -> None:
"""Главное свойство: стрик 0 не мешает сводке — она меряет календарь, а не серию."""
sentry = MagicMock()
with patch.object(sched, "sentry_sdk", sentry):
stale = sched.emit_stale_digest(_db(PROD_RUN_ROWS), now=NOW)
zero_streak = {"avito_full_load_exhaustive", "cian_history_backfill"}
assert zero_streak <= {s.source for s in stale}
def test_digest_is_quiet_when_everything_is_fresh() -> None:
sentry = MagicMock()
fresh = [_row("avito_city_sweep", None, 1.1), _row("sber_index_pull", 7, 4.1)]
with patch.object(sched, "sentry_sdk", sentry):
assert sched.emit_stale_digest(_db([_run_row(r) for r in fresh]), now=NOW) == []
sentry.capture_message.assert_not_called()
def test_digest_is_daily_not_per_tick() -> None:
"""Планировщик тикает раз в минуту; сводка обязана выходить раз в сутки."""
sentry = MagicMock()
db = _db(PROD_RUN_ROWS)
with patch.object(sched, "sentry_sdk", sentry):
sched.emit_stale_digest(db, now=NOW)
sched.emit_stale_digest(db, now=NOW + timedelta(minutes=1))
sched.emit_stale_digest(db, now=NOW + timedelta(hours=23))
assert sentry.capture_message.call_count == 1
sched.emit_stale_digest(db, now=NOW + timedelta(hours=24, minutes=1))
assert sentry.capture_message.call_count == 2
def test_digest_failure_never_breaks_the_tick() -> None:
"""Сводка — best-effort: упавший запрос не имеет права уронить тик планировщика."""
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
with patch.object(sched, "sentry_sdk", MagicMock()):
assert sched.emit_stale_digest(db, now=NOW) == []
# ── 3. Контроль: что именно сводка ДОБАВЛЯЕТ к лестнице ──────────────────────
@pytest.mark.parametrize(
("name", "streak"),
[("avito_full_load_exhaustive", 0), ("cian_history_backfill", 0), ("avito_full_load", 31)],
)
def test_ladder_is_silent_for_the_worst_two(name: str, streak: int) -> None:
"""КОНТРОЛЬ (зелёный и до правки): у трёх худших источников лестница молчит.
Стрик 0 — прогонов нет / серию обнулил 'cancelled'; стрик 31 — между вехами 24 и 48.
"""
rows = [SimpleNamespace(status="banned") for _ in range(streak)]
rows += [SimpleNamespace(status="done") for _ in range(3)]
sentry = MagicMock()
with patch.object(kit_runs, "sentry_sdk", sentry):
kit_runs._alert_if_consecutive_failures(_db(rows), name)
sentry.capture_message.assert_not_called()