Топология подтверждена перед удалением (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.
196 lines
8.9 KiB
Python
196 lines
8.9 KiB
Python
"""Мониторинг свежести ДАННЫХ 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
|