fix(tradein): чинит такт загрузки СберИндекса — иначе новый ERROR стал бы ложной тревогой (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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

Ревью 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
This commit is contained in:
bot-backend 2026-08-06 02:53:26 +05:00
parent 46bbb79881
commit 3e1b9a8b0d
5 changed files with 169 additions and 12 deletions

View file

@ -55,6 +55,11 @@ sber_index.py для sberindex.ru (см. #922, тот же паттерн: пу
- Портал ответил не-200 на листинг каталога/папки ERROR. Каталог единственная
опора поллера; портал УЖЕ один раз переехал (см. "ИСТОРИЯ"), и тогда поллер молча
врал целыми кварталами. Такое обязано быть событием.
- Файл датасета НАЙДЕН в листинге, но HEAD не отдал zip / размер ниже порога
ERROR. Тот же класс: это ровно поведение старой Bitrix-заглушки (200 + text/html).
Ветка может сработать легитимно (файл выложили в листинг раньше, чем докачали),
но цена асимметрична ложное срабатывание стоит одного события в месяц (такт
28 дней), пропуск стоит квартала молчания.
- Таймаут / сетевая ошибка WARNING, как раньше. Это транспортный блип раз в месяц
(такт поллера), сам пройдёт; а «квартал так и не приехал» ловит отдельный
deals_freshness_monitor ERROR-ом по max(deal_date).
@ -322,7 +327,13 @@ async def check_new_quarter_available(
)
return True
logger.info(
# ERROR (#2674, ревью PR #2681): файл ЕСТЬ в листинге, но HEAD отдал не zip
# либо размер ниже порога — это буквально тот сбой, из-за которого поллер уже
# врал (Bitrix-заглушка отвечала 200 с text/html вместо архива, см. "ИСТОРИЯ").
# Ветка может сработать и легитимно — файл появился в листинге раньше, чем
# докачался, — но цена асимметрична: такт 28 дней, значит ложное срабатывание
# стоит максимум одного события в месяц, а пропуск стоит квартала молчания.
logger.error(
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
"treating as unavailable",

View file

@ -14,21 +14,34 @@
#2674 — почему ERROR, а не WARNING. В контейнере скрапера GlitchTip поднят с
LoggingIntegration(event_level=ERROR) (scheduler_main.py), поэтому WARNING
событием НЕ становится: на проде монитор отработал 24 раза, из них 9 со
staleness-вердиктом и ни одного события. Бенчмарк цен участвует в сверке наших
медиан, его застой сбой, а не наблюдение. Сосед по конструкции
(deals_freshness_monitor) писал ERROR с самого начала расходилась только эта
джоба.
событием НЕ становится вообще. Бенчмарк цен участвует в сверке наших медиан, его
застой сбой, а не наблюдение. Сосед по конструкции (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 лейбл
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» алерта нет
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 alert=1.
публикации СберИндекса: источник отстаёт на 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 в

View file

@ -0,0 +1,68 @@
-- 212_sber_index_pull_weekly.sql
-- sber_index_pull: такт 28 дней → 7. Ревью PR #2681 (#2674).
--
-- ПОЧЕМУ. Монитор sber_freshness_monitor алертил при age > 60д
-- (sber_index_max_age_days 35 + lag_allowance 25). Прод-разбор всех 24 прогонов
-- монитора (read-only, 2026-08-06, scrape_runs.counters) показал ПИЛУ, а не застой:
--
-- 13-16.07 alert=1 age 73,74,75,76 latest_month=5 (май)
-- 17.07 alert=0 age 46 latest_month=6 ← день загрузки
-- 18-31.07 alert=0 age 47..60 latest_month=6
-- 01-05.08 alert=1 age 61..65 latest_month=6
--
-- Механика: загрузка ходила раз в 28 дней и приносила период на месяц новее, а
-- возраст считается от ПЕРВОГО ЧИСЛА покрытого месяца. Значит в момент самой
-- свежей загрузки возраст уже ~46 (07-17 минус 06-01), к следующей дорастает до
-- 46+28=74, и порог 60 лежит ВНУТРИ [46, 74] — тревога пересекала его каждый
-- цикл, 14 суток из 28. Девять срабатываний, поданных в #2674 как улика застоя
-- бенчмарка, — это замер НАШЕГО СОБСТВЕННОГО ТАКТА. После #2681 (WARNING → ERROR)
-- это стало бы ежедневным событием две недели в месяц, гаснущим само собой —
-- ровно та ложная тревога, которая приучает не читать алерты.
--
-- ПОЧЕМУ ТАКТ, А НЕ ПОРОГ. Рассматривались два варианта:
-- (A) поднять lag_allowance 25 → 40 (порог 75 против потолка 74). Запас ОДИН
-- день: любой сдвиг окна/пропуск прогона на сутки — и ложная тревога
-- возвращается. Порог при этом продолжает кодировать наш такт, а не
-- поведение источника. Отклонено.
-- (B) ЭТА миграция: такт 28 → 7. Потолок возраста становится floor+7 ≈ 53 при
-- том же пороге 60 — запас 7 суток, т.е. один пропущенный недельный цикл
-- поглощается, два подряд дают тревогу (и это уже осмысленная тревога).
-- Порог 60 начинает означать ИМЕННО «Сбер перестал публиковать / загрузка
-- сломалась», а не «мы давно не ходили».
--
-- ЦЕНА. pull_sber_indices делает SBER_REF_AREAS (3: 643/66/77) × SBER_DASHBOARDS
-- (3) = 9 GET-запросов к публичному неавторизованному sberindex.ru/api/sowa, без
-- пауз в цикле; прод-прогон 2026-07-17 занял 4 секунды (counters.duration_sec=4,
-- errors=0, upserted=639). Было 9 запросов / 28 дней, стало 9 / 7 дней = 36 в
-- месяц. Это тот же эндпоинт, который дёргают сами дашборды Сбера при каждом
-- открытии страницы; лимитов/бана на нём за всю историю прогонов не наблюдалось
-- (0 ошибок в 6 прогонах). Риск нагрузки считаем отсутствующим.
--
-- ПОБОЧНО. Оценщик имеет СВОЙ per-estimate guard свежести с порогом
-- settings.sber_index_max_age_days=35. Он пробивается всегда, потому что возраст
-- стартует с ~46. Недельный такт сокращает НАШУ задержку обнаружения с ≤28 суток
-- до ≤7, то есть возраст = (лаг публикации Сбера) + ≤7 вместо + ≤28. Уйдёт ли он
-- под 35 — зависит от того, когда Сбер реально публикует месяц (по нашим данным
-- лаг публикации ≤46 и ≥31 суток, точнее по имеющимся прогонам не определить),
-- поэтому НЕ обещаем починку этого guard'а, только снятие нашей части задержки.
--
-- next_run_at подтягиваем на ближайшее окно (05:00-06:00 UTC): без этого правка
-- default_params начнёт действовать только после уже запланированного прогона
-- 2026-08-14, а до тех пор ложная тревога продолжала бы идти каждый день.
-- LEAST() — чтобы повторное применение НИКОГДА не отодвигало прогон дальше.
--
-- Идемпотентно: jsonb-конкатенация + LEAST, повторный прогон безопасен.
-- Кода не меняет: interval_days читается kit-планировщиком из default_params
-- (orchestration/scheduler.py::_defer_next_run_at, params.get("interval_days", 1)).
BEGIN;
UPDATE scrape_schedules
SET default_params = COALESCE(default_params, '{}'::jsonb) || '{"interval_days": 7}'::jsonb,
next_run_at = LEAST(
next_run_at,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC'
)
WHERE source = 'sber_index_pull';
COMMIT;

View file

@ -66,7 +66,11 @@ def glitchtip_events() -> Iterator[list[dict[str, Any]]]:
)
with sentry_sdk.isolation_scope() as scope:
scope.set_client(client)
yield events
try:
yield events
finally:
# Иначе на каждый тест остаётся фоновый поток транспорта.
client.close()
def event_texts(events: list[dict[str, Any]]) -> list[str]:
@ -304,6 +308,32 @@ async def test_rosreestr_broken_index_becomes_event() -> None:
assert any("unexpected HTTP 503" in t for t in event_texts(events))
async def test_rosreestr_stub_instead_of_zip_becomes_event() -> None:
"""Файл есть в листинге, но HEAD отдал заглушку — тот сбой, из-за которого уже врали.
Ровно поведение старой Bitrix-заглушки: HTTP 200 + text/html вместо архива.
"""
index_html = '<a href="3%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./">q</a>'
folder_html = '<a href="dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_3.csv.zip">f</a>'
client = MagicMock()
client.get = AsyncMock(
side_effect=[
httpx.Response(200, text=index_html),
httpx.Response(200, text=folder_html),
]
)
client.head = AsyncMock(
return_value=httpx.Response(
200, text="stub", headers={"content-type": "text/html", "content-length": "512"}
)
)
with glitchtip_events() as events:
available = await rosreestr_poll.check_new_quarter_available(client, 2026, 3)
assert available is False
assert any("soft-404 guard" in t for t in event_texts(events))
async def test_rosreestr_quarter_not_published_is_silent() -> None:
"""Каталог жив, папки квартала ещё нет — самый частый прогон, событий быть не должно."""
with glitchtip_events() as events:

View file

@ -28,6 +28,7 @@ from app.tasks import sber_freshness_monitor as mon
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
_MIGRATION_212 = _SQL_DIR / "212_sber_index_pull_weekly.sql"
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
_MAY_2026 = date(2026, 5, 1)
@ -218,6 +219,40 @@ def test_migration_180_no_psycopg_trap() -> None:
assert not re.search(r":\w+::", sql)
# ── Миграция 212: такт загрузки не должен пересекать порог монитора ───────────
#
# Прод-разбор (ревью PR #2681): загрузка раз в 28 дней давала возраст-пилу 46..74
# при пороге 60 — тревога срабатывала 14 суток из 28 БЕЗ всякого застоя источника.
# Тест держит инвариант: потолок возраста (пол + такт загрузки) < порога монитора.
def test_migration_212_makes_pull_cadence_weekly() -> None:
sql = _MIGRATION_212.read_text("utf-8")
assert "sber_index_pull" in sql
assert '"interval_days": 7' in sql
assert "BEGIN;" in sql and "COMMIT;" in sql
assert not re.search(r":\w+::", sql) # psycopg v3: только CAST(:x AS type)
def test_pull_cadence_leaves_margin_under_monitor_threshold() -> None:
"""Инвариант: пол возраста + такт загрузки < порога монитора.
Пол = 46 суток (прод 2026-07-17: загрузка принесла 2026-06-01). Порог =
sber_index_max_age_days + lag_allowance. При такте 7: 46+7=53 < 60 запас
7 суток. При прежних 28: 46+28=74 > 60 тревога каждый цикл, что и наблюдали.
"""
interval_days = int(
re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1)
)
observed_floor_days = 46
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
assert observed_floor_days + interval_days < threshold, (
f"такт {interval_days}д даёт потолок возраста "
f"{observed_floor_days + interval_days}д при пороге {threshold}д — "
"монитор снова будет мерить наш такт, а не застой источника"
)
# ── Регистрация в kit registry ─────────────────────────────────────────────────