All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Джоба-статус rosreestr_* остаётся done при quarter-stale данных (inserted=0 == всё импортировано), маскируя пропущенный операторский импорт нового квартала. Новый daily-монитор смотрит max(deal_date) и поднимает ERROR-алерт (→ GlitchTip), когда следующий квартал просрочен публикацией (порог = конец имеющегося квартала + 3 мес + 45д лага). Расследование (2026-07-02): импорт НЕ сломан — deal_date = лейбл начала квартала, 2026Q1 = последний опубликованный Росреестром (Q2 закончился 30.06, публикация ожидается ~август; исторические кварталы загружены разово 26.04). Монитор ловит именно пропуск ОПЕРАТОРСКОГО шага (data/sql/02_load_all_quarters.sh), когда quarter_poll задетектит Q2. - app/tasks/deals_freshness_monitor.py — чистая evaluate_deals_freshness (frozen-now testable) + DB-обёртка; overdue → ERROR-лог, не failed - scheduler.py + product_handlers.py — оба dispatch-пути (kit боевой + legacy) - data/sql/162 — seed (daily, 08:00-09:00 UTC, lag_allowance_days=45) - 23 теста: арифметика порога (год-переход, clamp), migration props, parity Refs #2212
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)) — запускается in-app scheduler'ом
|
||
через trigger_deals_freshness_monitor_run() в 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
|