All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 2m59s
CI / changes (pull_request) Successful in 7s
Ревью PR #2681 опровергло исходную посылку по СберИндексу, и это подтвердилось на моих же числах (все 24 прогона монитора, read-only): 13-16.07 alert=1 age 73..76 latest=май 17.07 alert=0 age 46 latest=июнь ← день загрузки 18-31.07 alert=0 age 47..60 01-05.08 alert=1 age 61..65 Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст считается от первого числа покрытого месяца → пол 46, потолок 74, порог 60 ВНУТРИ диапазона. Тревога срабатывала 14 суток из 28 без всякого застоя источника: девять срабатываний были замером нашего собственного такта. Поднятие до ERROR без этой правки завело бы ежедневное ложное событие две недели в месяц. Миграция 212 переводит sber_index_pull на недельный такт (потолок ≈53 при пороге 60, запас 7 суток) вместо поднятия порога до 75 (запас 1 сутки — ломается от любого сдвига окна). Цена: 9 запросов в неделю вместо 9 в 28 дней к публичному sberindex.ru/api/sowa; прогон 4 секунды, 0 ошибок за всю историю. Дополнительно по ревью: - поллер Росреестра: ветка «файл найден в листинге, но HEAD не отдал zip» → ERROR (ровно поведение старой Bitrix-заглушки) + вписана в таблицу уровней; - тестовый харнесс закрывает клиент событий (фоновый поток на каждый тест). Refs #2674
222 lines
12 KiB
Python
222 lines
12 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 ERROR-алерт, когда данные устарели СВЕРХ допустимого лага
|
||
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||
|
||
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
|
||
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
|
||
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
|
||
застой — сбой, а не наблюдение. Сосед по конструкции (deals_freshness_monitor)
|
||
писал ERROR с самого начала — расходилась только эта джоба.
|
||
|
||
ВАЖНО про «9 срабатываний» из #2674 (ревью PR #2681, прод-разбор всех 24 прогонов
|
||
монитора 2026-08-06). Эти девять НЕ были застоем бенчмарка — это была ПИЛА нашего
|
||
собственного такта загрузки:
|
||
13-16.07 alert=1 age 73..76 latest=май 01-05.08 alert=1 age 61..65
|
||
17.07 alert=0 age 46 latest=июнь (день загрузки)
|
||
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст же
|
||
считается от ПЕРВОГО числа покрытого месяца → пол ~46 в момент загрузки, потолок
|
||
46+28=74, порог 60 ВНУТРИ диапазона, тревога 14 суток из 28 каждый цикл. Поднимать
|
||
такое до ERROR без починки такта значило бы завести ежедневное ложное событие на
|
||
две недели в месяц. Поэтому миграция 212 перевела sber_index_pull на НЕДЕЛЬНЫЙ
|
||
такт: потолок возраста ≈ пол+7 ≈ 53 при пороге 60, тревога снова означает
|
||
«источник/загрузка встали», а не «мы давно не ходили».
|
||
|
||
Порог алерта (документирование выбора):
|
||
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 — лейбл
|
||
ПЕРВОГО числа месяца, поэтому даже свежайшая загрузка даёт возраст ~46 суток.
|
||
Итог: 35 + 25 = 60д. При недельном такте (миграция 212) рабочий диапазон возраста
|
||
~46..53 — до порога остаётся ~7 суток запаса: один пропущенный недельный цикл
|
||
поглощается, два подряд дают тревогу. Порог НЕ должен снова оказаться внутри
|
||
рабочего диапазона — если такт загрузки будут менять, пересчитай потолок
|
||
(пол + interval_days) и сверь с 60.
|
||
|
||
Задача синхронная (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 при алерте (это МОНИТОР, а не сбой джобы) — ERROR-записи
|
||
достаточно. 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:
|
||
# ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой,
|
||
# а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд),
|
||
# а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке.
|
||
logger.error(
|
||
"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:
|
||
# ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR) —
|
||
# 9 срабатываний на проде дали ноль событий. См. докстринг модуля.
|
||
logger.error(
|
||
"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
|