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 ─────────────────────────────────────────────────