gendesign/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py
bot-backend 85059aeb1b refactor(tradein/scheduler): удалить legacy scheduler_loop + scraper-scheduling, kit единственный путь (#2397 Part C)
Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend
(uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m
app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь
(_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers)
самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop
или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler
диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/
app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill
из scheduler.py) — мимо удаляемой legacy-машинерии.

app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop,
get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/
_drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт
app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS
(достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые
импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py),
import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в
product_handlers.py — job-тела kit-handler'ов).

main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок
запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`);
прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код.

scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false →
legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь.
Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore"
защищает от startup-краха на leftover env var), но на ветвление не влияет.

app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/
после этого PR (только тесты, которые Part E удалит вместе с самим файлом) —
подтверждено grep. scrape_pipeline.py не тронут (Part E).

Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер,
backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py /
test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант
против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py).
Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_
wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами
task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми.
test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты).
test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против
удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/
test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/
registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не
app.services.scheduler, а сам scraper_kit.orchestration.scheduler.
test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop,
переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR).
test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из
scrape_pipeline) намеренно НЕ тронуты — Part E.

Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех
изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
2026-07-04 13:00:30 +03:00

196 lines
8.9 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.

"""Мониторинг свежести ДАННЫХ deals (не статуса джобы) — #2212.
Проблема аудита: rosreestr_quarter_poll / rosreestr_dkp_import честно рапортуют
`done` (rows_fetched>0, inserted=0 == «всё уже импортировано»), но это МАСКИРУЕТ
staleness самих данных. `deals.deal_date` — лейбл начала квартала (2026-01-01 = 2026Q1);
Росреестр публикует квартальными дампами, скачивание дампа — РУЧНОЙ операторский шаг
(poll лишь алертит о доступности). Если оператор пропустил импорт нового квартала,
джоба-статус остаётся зелёным, а данные тихо устаревают.
Этот монитор смотрит на `max(deal_date)` и поднимает ERROR-алерт (→ GlitchTip через
sentry logging integration), когда следующий квартал ПРОСРОЧЕН публикацией.
Арифметика просрочки (проверена на проде 2026-07-02, max=2026Q1, q_end=2026-03-31):
latest_q_start = max(deal_date) (2026-01-01)
latest_q_end = latest_q_start + 3 months (2026-04-01, старт Q2)
overdue_threshold = latest_q_end + 3 months + LAG_DAYS (2026-07-01 + 45д = 2026-08-15)
overdue = now() > overdue_threshold
Смысл: следующий квартал (Q2) заканчивается через 3 мес после конца имеющегося; даём
LAG_ALLOWANCE_DAYS (по умолчанию 45) на публикацию дампа Росреестром. Сейчас (2026-07-02)
до порога 2026-08-15 ещё далеко → алерта НЕТ. Если к 2026-08-16 Q2 не импортирован → алерт.
Задача синхронная (DB-only, один SELECT max(deal_date)) — запускается kit-scheduler'ом
через product_handlers._job_deals_freshness_monitor в run_in_executor, по образцу
listing_source_snapshot / rosreestr_dkp_import. Вердикт вычисляет ЧИСТАЯ функция
evaluate_deals_freshness() (frozen-now тестируется без БД).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-лог
достаточен. mark_failed только если deals недоступна/пуста (нечего оценивать).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_LAG_ALLOWANCE_DAYS",
"FreshnessVerdict",
"check_deals_freshness",
"evaluate_deals_freshness",
]
# Лаг публикации дампа Росреестром: сколько дней после конца следующего квартала
# считаем допустимой задержкой до алерта. Читается из default_params.lag_allowance_days.
DEFAULT_LAG_ALLOWANCE_DAYS = 45
_LATEST_DEAL_DATE_SQL = text("SELECT max(deal_date) AS latest FROM deals")
@dataclass(frozen=True)
class FreshnessVerdict:
"""Вердикт свежести deals по max(deal_date)."""
latest_q_start: date
latest_q_end: date
overdue_threshold: date
overdue: bool
days_overdue: int
def _add_months(d: date, months: int) -> date:
"""Прибавить `months` к дате. deal_date всегда старт квартала (день=1), но
хелпер устойчив к произвольному дню — при переполнении зажимает на конец месяца.
"""
total = (d.month - 1) + months
year = d.year + total // 12
month = total % 12 + 1
# Зажим дня на последний день целевого месяца (для дней 29-31; для day=1 no-op).
if month == 12:
next_month_first = date(year + 1, 1, 1)
else:
next_month_first = date(year, month + 1, 1)
last_day = (next_month_first - timedelta(days=1)).day
return date(year, month, min(d.day, last_day))
def evaluate_deals_freshness(
latest_q_start: date,
now: datetime,
lag_allowance_days: int = DEFAULT_LAG_ALLOWANCE_DAYS,
) -> FreshnessVerdict:
"""Чистая логика: просрочен ли следующий квартал относительно max(deal_date).
overdue = now() > (latest_q_start + 6 месяцев + lag_allowance_days).
days_overdue = max(0, дни сверх порога) — 0 когда не просрочено.
Тестируется с frozen `now` без БД.
"""
latest_q_end = _add_months(latest_q_start, 3)
overdue_threshold = _add_months(latest_q_start, 6) + timedelta(days=lag_allowance_days)
now_date = now.date()
overdue = now_date > overdue_threshold
days_overdue = max(0, (now_date - overdue_threshold).days)
return FreshnessVerdict(
latest_q_start=latest_q_start,
latest_q_end=latest_q_end,
overdue_threshold=overdue_threshold,
overdue=overdue,
days_overdue=days_overdue,
)
def check_deals_freshness(
db: Session,
run_id: int,
params: dict | None = None, # type: ignore[type-arg]
now: datetime | None = None,
) -> dict[str, int]:
"""Проверить свежесть deals по max(deal_date) и поднять алерт при просрочке.
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
Читает один SELECT max(deal_date), считает вердикт чистой функцией, логирует
ERROR при overdue (→ GlitchTip) и финализирует run.
Params (default_params jsonb):
lag_allowance_days: int — допустимый лаг публикации дампа (default 45).
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
Returns counters {latest_year, latest_quarter, days_overdue, alert}.
mark_failed только если deals пуста/недоступна (нечего оценивать); при алерте
прогон помечается done (это монитор, не сбой джобы).
"""
params = params or {}
now = now or datetime.now(UTC)
counters: dict[str, int] = {
"latest_year": 0,
"latest_quarter": 0,
"days_overdue": 0,
"alert": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
row = db.execute(_LATEST_DEAL_DATE_SQL).first()
latest: date | None = row.latest if row is not None else None
if latest is None:
logger.warning(
"deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя"
)
runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters)
return counters
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
verdict = evaluate_deals_freshness(latest, now, lag_days)
quarter = (latest.month - 1) // 3 + 1
counters = {
"latest_year": latest.year,
"latest_quarter": quarter,
"days_overdue": verdict.days_overdue,
"alert": int(verdict.overdue),
}
if verdict.overdue:
logger.error(
"deals freshness: max(deal_date)=%s — новый квартал просрочен на %d дней; "
"проверь rosreestr_quarter_poll + операторский импорт "
"(data/sql/02_load_all_quarters.sh)",
latest,
verdict.days_overdue,
)
else:
logger.info(
"deals freshness: max(deal_date)=%s (Q%d %d) свежий — порог просрочки %s "
"ещё не наступил, алерта нет",
latest,
quarter,
latest.year,
verdict.overdue_threshold,
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"check_deals_freshness run_id=%d done: latest=%s alert=%d days_overdue=%d",
run_id,
latest,
counters["alert"],
counters["days_overdue"],
)
return counters
except Exception as exc:
logger.exception("check_deals_freshness run_id=%d failed", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise