fix(tradein/sber): сторож мерит отставание загрузки, а не календарь (#2846) #2849
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2849
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/sber-freshness-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что было не так
Порог
sber_index_max_age_days = 35недостижим по построению.period_month— метка ПЕРВОГО числа месяца, значит возрасту ≥30 уже на закрытии месяца; плюс лаг публикации источника. За 31 сутки прямых наблюдений монитора (07-13…08-12,scrape_runs.counters) возраст лежал в 46..76 и ни разу не опускался ниже 46. Сторож был истинным 100% времени и нёс ноль бит: при живом источнике и при мёртвом загрузчике писал одно и то же.Второй порог (монитор, 60 = 35 + запас 25) лежит ВНУТРИ рабочего диапазона. Обещание миграции 212 («такт 7 ⇒ потолок возраста ≈53 < 60») прод опроверг: 2026-08-12 возраст 72 при ПОЛНОМ прогоне загрузки 08-06 (errors=0, upserted=639). Двенадцатые сутки подряд (08-01…08-12) монитор писал ERROR при исправной загрузке. Потолок 53 держался бы, только если бы источник публиковал строго помесячно — он не публикует (июль не вышел и на 42-е сутки).
Форма нового сигнала
Загрузчик тянет ВСЮ серию (
limit=1000&offset=0, отсечки по периоду нет), поэтому после прогона сerrors=0 AND upserted>0нашmax(period_month)равен максимуму источника по построению. Значит:status='done'за успех не считается — прогон id=37 имеетdoneпри{errors: 9, upserted: 0}. Живая проверка SQL сторожа на проде: 8 полных прогонов из 9, id=37 отфильтрован, последний 2026-08-06,pull_lag_days=6при пороге 14 → алерта нет (загрузка исправна, источник молчит).Порог обоснован замером, а не круглым числом:
2 × scrape_schedules.default_params.interval_daysдляsber_index_pull, читается из ТОЙ ЖЕ строки, по которой планировщик считаетnext_run_at. Разъехаться с тактом не может. Все 8 полных прогонов укладывались в свой тогдашний такт (разрывы 14.8 / 20 / 28 суток — 20 объясняется самой миграцией 212, подтянувшейnext_run_atна 08-06); двойной запас поглощает один пропущенный цикл, два подряд дают тревогу.Вопрос задаётся ПО ТАБЛО. Монитор идёт тем же порядком, что оценщик (
SBER_COEFF_DASHBOARDS), и берёт ту же серию:max()по таблице маскирует отставшее табло (real_estate_deals 2026-06 против dinamika-tsen-obyavlenii 2026-05).Два порога сведены в один факт — удалением
settings.sber_index_max_age_daysудалён, per-estimate warning вestimator._load_sber_index_seriesубран. Свежесть считает ровно одно место, второму порогу неоткуда взяться (тестtest_no_second_calendar_threshold_in_settingsэто держит). Мёртвая ручкаdefault_params.lag_allowance_days=25игнорируется — тест доказывает, что она не может вернуть календарь. Отдельной миграцией её может убрать database-expert; кода это не касается.fetched_atУбран из
DO UPDATE SET(вариант «завестиfirst_seen_at» отклонён: требует миграции + бэкфилла, который честным быть не может — истинного времени первого появления у 639 лежащих строк нет). Теперь колонка означает «когда мы ВПЕРВЫЕ увидели период», то есть такт публикации источника станет измеримым. Ретроспективу это не возвращает — у существующих строк метка 2026-08-06 и останется. Времени последней загрузки колонка больше не хранит; оно и не нужно —scrape_runsхранит его точнее (с errors/upserted), сторож берёт оттуда. Читателей у колонки в коде нет.Два вопроса к новому сторожу
Красный прогон
Новые тесты на
origin/main(отдельный worktree, тот же файл):На ветке:
26 passed. Полный бэкенд-сьют:4321 passed, 21 skipped.Двусторонность.
test_alert_tracks_loader_not_calendarгоняет ДВА состояния с ОДИНАКОВЫМ возрастом периода (72) и разным состоянием загрузки →(0, 1). На main оба дают1: сторож не умеет зеленеть при живом источнике — это и был исходный дефект. Плюс граница порога в обе стороны (14 → тихо, 15 → алерт) иtest_sber_old_period_with_healthy_pull_stays_silent(событий GlitchTip ноль) противtest_sber_pull_stall_becomes_event(событие есть).Побочное: ярлык сегмента (п.6 задачи) — НЕ чиню здесь, обосновываю
135 строк
residential_real_estate_pricesнесутsegment='Первичный рынок', хотя загрузчик проситREAL_ESTATE_TYPE=2с комментарием «2=вторичка». Значения говорят, что прав ЯРЛЫК, а не комментарий: обл.66 за 6 месяцев 160 474…163 914 ₽/м² против вторичных сделок 97 973…99 547 (+65%) и asking 93 566…101 600; по России 185 709…187 401 против 123 606…127 165 (+50%). Ряд «вторички» от того же издателя не может быть на 65% выше его же индекса вторичных сделок — это новостройки.Правка требует ЖИВОГО запроса к источнику (какой код
REAL_ESTATE_TYPEдаёт вторичку), а он в этой задаче запрещён. Денежного эффекта сегодня ноль: табло не входит вSBER_COEFF_DASHBOARDS(убрано в #R2-H1 именно из-за ярлыка), и оба SQL фильтруютsegment ILIKE '%вторичн%'— строки до оценки не доходят. При этом одно из двух утверждений в коде ложно, и цена вопроса — один живой запрос. Отдельным issue, а не здесь: если ярлык верен неверно и это на самом деле вторичка, починка вернула бы в оценку серию, убранную #R2-H1, то есть изменила бы вход оценки — что этому PR запрещено.Предложение, кода не касающееся
Применение якоря не тронуто (замер: 1-3.5% у ~4% оценок, 0.00 пп MAPE). Если вернётесь к нему — заметьте, что на 2026-08-12 применяются ровно два множителя, потому что в 12-месячном окне сделок всего два месяца.
Test plan
uv run pytest tests/test_sber_freshness_monitor.py tests/test_alerts_become_events.py tests/test_estimator_audit_fixes.pySELECT counters FROM scrape_runs WHERE source='sber_freshness_monitor' ORDER BY id DESC LIMIT 1→ ждёмalert=0,pull_lag_days≈6..13,max_pull_lag_days=14(и конец 12-суточной серии ложных ERROR)sber_index_pull(13.08 05:00 UTC): у НОВЫХ периодовfetched_atотличается от 2026-08-06 — колонка начала мерить такт публикацииRefs #2846
Порог свежести якоря был недостижим по построению. period_month — метка ПЕРВОГО числа месяца, поэтому возрасту ≥30 уже на закрытии месяца; плюс лаг публикации источника. За 31 сутки прямых наблюдений монитора (07-13…08-12, scrape_runs.counters) возраст лежал в 46..76 и ни разу не опускался ниже 46 — при пороге оценщика 35. Сторож был истинным 100% времени с рождения таблицы и нёс ноль бит: при живом источнике и при мёртвом загрузчике он писал одно и то же. Второй порог (монитор, 60 = 35 + запас 25) не лучше: он лежит ВНУТРИ рабочего диапазона. Обещание миграции 212 («такт 7 ⇒ потолок возраста 53 < 60») прод ОПРОВЕРГ — 2026-08-12 возраст 72 при полном прогоне загрузки 08-06; двенадцатые сутки подряд ERROR при исправной загрузке. Потолок 53 держался бы, только если бы источник публиковал строго помесячно. Что теперь. Загрузчик тянет ВСЮ серию (limit=1000&offset=0), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) равен максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» = «давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода. Порог — 2 такта самой загрузки, читается из scrape_schedules.default_params.interval_days, то есть из той же строки, по которой планировщик считает next_run_at: разъехаться с тактом он не может. status='done' за успех не считается — прогон id=37 имеет done при {errors: 9, upserted: 0}. Табло спрашивается тем же порядком, что у оценщика (SBER_COEFF_DASHBOARDS), потому что max() по таблице маскирует отставшее табло: real_estate_deals 2026-06, dinamika-tsen-obyavlenii 2026-05. Два порога сведены удалением: settings.sber_index_max_age_days и per-estimate warning в estimator убраны, свежесть считает ровно одно место. fetched_at больше не переписывается апсертом. Забор идёт всей серией, поэтому fetched_at = now() в DO UPDATE ставил одну метку всем 639 строкам, включая период 2017-01 — как признак свежести колонка была пуста. Теперь она означает «когда впервые увидели период», т.е. такт публикации источника станет измеримым. Ретроспективу это не возвращает: у уже лежащих строк метка 2026-08-06 и останется. Refs #2846Проверил три ключевых числа сам — сошлись, и моя предпосылка действительно была неверной
status='done'подтверждена (id=37 сerrors=9, upserted=0отброшен)next_run_atс 08-14 на 08-06Разрывы между полными прогонами: 14.8 · 0.6 · 1.0 · 1.0 · 1.0 · 28.0 · 20.0. Двадцативосьмисуточный равен ровно тогдашнему такту — то есть загрузчик не опоздал ни разу за всю историю.
Отсюда важное уточнение к обоснованию порога, и хорошо, что оно записано в отчёте прямо:
MISSED_PULL_CYCLES = 2— это запас над нулевой наблюдённой просрочкой, а не над измеренной. Замер отвечает «мы не опаздывали», а не «мы опаздывали столько-то». Формулировка честная, и её стоит сохранить при будущих правках порога.Вывод про недостижимость исходного порога устоял: пол структурный (первое число месяца + лаг публикации), а не наблюдённый, и от числа наблюдений не зависит.
Что в этой правке ценно сверх заявленного
Пороги сведены удалением, а не согласованием. Два числа на один факт (35 у оценщика, 60 у монитора) не помирены, а сокращены до одного места, плюс тест держит отсутствие второго. Согласованные константы расходятся; удалённые — нет.
Двусторонность доказана на равном возрасте.
test_alert_tracks_loader_not_calendar: два состояния с одинаковым возрастом 72 и разной загрузкой дают(0, 1), а наorigin/mainоба дают1. Это и есть прямое доказательство, что старый сторож не умел зеленеть, — а не рассуждение о том, что он не умел.Найден тавтологический тест на main.
test_pull_cadence_leaves_margin_under_monitor_thresholdсчитал46 + 7 < 60из констант и остался бы зелёным, пока прод показывает 72. Заменён на сверку фолбэка кода с тактом миграции — этот краснеет, если их разведут.Граница названа честно. Новый сторож не ловит «источник замолчал навсегда при исправной загрузке»: такт публикации затёрт апсертом, и «не публиковал 2 месяца» неотличимо от «публикует раз в 2 месяца». Записано в докстринге модуля, а не только в отчёте, и с ориентиром на возврат — после трёх наблюдённых публикаций.
Ярлык сегмента не тронут по верной причине. Числа говорят, что прав ярлык, а комментарий кода врёт (+65% к вторичным сделкам — ряд вторички не бывает настолько выше индекса вторичных сделок того же издателя). Но починка вернула бы в оценку серию, убранную ранее, то есть изменила бы вход оценки — чего этому PR делать нельзя. Отложено верно.
Проверять после деплоя
countersпоследнего прогонаsber_freshness_monitor: ждёмalert=0,pull_lag_days6…13,max_pull_lag_days=14— и конец 12-суточной серии ложных ERROR.fetched_atобязан отличаться от 2026-08-06. Если он снова одинаков у всей таблицы — правка апсерта не доехала.