All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
Добавляет sber_freshness_monitor по образцу deals_freshness_monitor: staleness данных СберИндекса теперь видна на MONITOR-частоте, а не тонет в per-estimate warning'ах estimator._load_sber_index_series (#audit-5a). - app/tasks/sber_freshness_monitor.py: чистая evaluate_sber_freshness() (frozen-now, без БД) + check_sber_freshness() (один SELECT max(period_month) вторичного сегмента по региону, #R2-H1 фильтр как в эстиматоре; WARNING при stale, mark_done при алерте — это монитор, не сбой; mark_failed только при пустой таблице). - app/services/product_handlers.py: _job_sber_freshness_monitor + Handler в build_product_handlers (run_in_executor, как deals-монитор). - data/sql/180_seed_sber_freshness_monitor.sql: seed scrape_schedules (enabled, daily 09:00-10:00 UTC, lag_allowance_days=25). - tests/test_sber_freshness_monitor.py: frozen-now (fresh/stale/границы) + FakeDB (fresh/stale/empty/кастомный lag) + свойства миграции + registry. Порог алерта: sber_index_max_age_days (35) + lag_allowance (25) = 60д. +25 — запас на инхерентный лаг публикации источника (1-2 мес), чтобы не шуметь на штатном отставании. Прод 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
196 lines
9.1 KiB
Python
196 lines
9.1 KiB
Python
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
|
||
|
||
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
|
||
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
|
||
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
|
||
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
|
||
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
|
||
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
|
||
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||
|
||
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
|
||
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||
|
||
Порог алерта (документирование выбора):
|
||
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
||
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл
|
||
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
|
||
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет
|
||
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
|
||
→ алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
|
||
|
||
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||
|
||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING
|
||
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||
оценивать).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, date, datetime
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.services import scrape_runs as runs_mod
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
__all__ = [
|
||
"DEFAULT_LAG_ALLOWANCE_DAYS",
|
||
"SberFreshnessVerdict",
|
||
"check_sber_freshness",
|
||
"evaluate_sber_freshness",
|
||
]
|
||
|
||
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
|
||
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
|
||
DEFAULT_LAG_ALLOWANCE_DAYS = 25
|
||
|
||
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
|
||
# sber_price_index для областного уровня.
|
||
SBER_MONITOR_CITY = "Свердловская область"
|
||
|
||
_LATEST_SBER_PERIOD_SQL = text("""
|
||
SELECT max(period_month) AS latest
|
||
FROM sber_price_index
|
||
WHERE city = CAST(:city AS text)
|
||
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
|
||
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
|
||
-- estimator._load_sber_index_series.
|
||
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||
""")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SberFreshnessVerdict:
|
||
"""Вердикт свежести СберИндекса по max(period_month)."""
|
||
|
||
latest_period: date
|
||
age_days: int
|
||
stale: bool
|
||
|
||
|
||
def evaluate_sber_freshness(
|
||
latest_period: date,
|
||
now: datetime,
|
||
max_age_days: int,
|
||
) -> SberFreshnessVerdict:
|
||
"""Чистая логика: устарел ли latest период СберИндекса.
|
||
|
||
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
|
||
`max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
|
||
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
|
||
"""
|
||
age_days = (now.date() - latest_period).days
|
||
stale = age_days > max_age_days
|
||
return SberFreshnessVerdict(
|
||
latest_period=latest_period,
|
||
age_days=age_days,
|
||
stale=stale,
|
||
)
|
||
|
||
|
||
def check_sber_freshness(
|
||
db: Session,
|
||
run_id: int,
|
||
params: dict | None = None, # type: ignore[type-arg]
|
||
now: datetime | None = None,
|
||
) -> dict[str, int]:
|
||
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
|
||
|
||
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
|
||
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
|
||
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
|
||
и финализирует run.
|
||
|
||
Params (default_params jsonb):
|
||
lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25).
|
||
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
|
||
|
||
Returns counters {latest_year, latest_month, age_days, alert}.
|
||
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
|
||
при алерте прогон помечается done (это монитор, не сбой джобы).
|
||
"""
|
||
params = params or {}
|
||
now = now or datetime.now(UTC)
|
||
counters: dict[str, int] = {
|
||
"latest_year": 0,
|
||
"latest_month": 0,
|
||
"age_days": 0,
|
||
"alert": 0,
|
||
}
|
||
try:
|
||
runs_mod.update_heartbeat(db, run_id, counters)
|
||
|
||
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
||
latest: date | None = row.latest if row is not None else None
|
||
if latest is None:
|
||
logger.warning(
|
||
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||
"(вторичка) — оценить свежесть нельзя",
|
||
SBER_MONITOR_CITY,
|
||
)
|
||
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
|
||
return counters
|
||
|
||
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
|
||
max_age_days = settings.sber_index_max_age_days + lag_days
|
||
verdict = evaluate_sber_freshness(latest, now, max_age_days)
|
||
|
||
counters = {
|
||
"latest_year": latest.year,
|
||
"latest_month": latest.month,
|
||
"age_days": verdict.age_days,
|
||
"alert": int(verdict.stale),
|
||
}
|
||
|
||
if verdict.stale:
|
||
logger.warning(
|
||
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||
"проверь sber_index_pull и доступность новых периодов источника",
|
||
latest,
|
||
verdict.age_days,
|
||
max_age_days,
|
||
settings.sber_index_max_age_days,
|
||
lag_days,
|
||
)
|
||
else:
|
||
logger.info(
|
||
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
|
||
"region=%s — алерта нет",
|
||
latest,
|
||
verdict.age_days,
|
||
max_age_days,
|
||
SBER_MONITOR_CITY,
|
||
)
|
||
|
||
runs_mod.mark_done(db, run_id, counters)
|
||
logger.info(
|
||
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
|
||
run_id,
|
||
latest,
|
||
counters["alert"],
|
||
counters["age_days"],
|
||
)
|
||
return counters
|
||
except Exception as exc:
|
||
logger.exception("check_sber_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
|