From 3e1b9a8b0de94dbf526dbaefcf2a2b3a8ab073b8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 02:53:26 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=87=D0=B8=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=20=D1=82=D0=B0=D0=BA=D1=82=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D0=A1=D0=B1=D0=B5=D1=80=D0=98=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D0=B0=20=E2=80=94=20=D0=B8=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20ERROR=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B1=D1=8B=20=D0=BB=D0=BE=D0=B6=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=82=D1=80=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=D0=B9=20(#2?= =?UTF-8?q?674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью 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 --- .../backend/app/services/rosreestr_poll.py | 13 +++- .../app/tasks/sber_freshness_monitor.py | 33 ++++++--- .../data/sql/212_sber_index_pull_weekly.sql | 68 +++++++++++++++++++ .../tests/test_alerts_become_events.py | 32 ++++++++- .../tests/test_sber_freshness_monitor.py | 35 ++++++++++ 5 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql diff --git a/tradein-mvp/backend/app/services/rosreestr_poll.py b/tradein-mvp/backend/app/services/rosreestr_poll.py index 3fa293b0..3d278acd 100644 --- a/tradein-mvp/backend/app/services/rosreestr_poll.py +++ b/tradein-mvp/backend/app/services/rosreestr_poll.py @@ -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", diff --git a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 22193491..487179d5 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -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 в diff --git a/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql new file mode 100644 index 00000000..07aaea2e --- /dev/null +++ b/tradein-mvp/backend/data/sql/212_sber_index_pull_weekly.sql @@ -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; diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py index a58d5910..ae084d93 100644 --- a/tradein-mvp/backend/tests/test_alerts_become_events.py +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -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 = 'q' + folder_html = 'f' + 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: diff --git a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py index 114322af..726d991e 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -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 ─────────────────────────────────────────────────