fix(mera): витрина сделок лэндинга — в расписание, дата прогона на страницу, монитор свежести (#3469) #3509
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3509
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/3469-showcase-schedule"
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?
Закрывает #3469.
Корень
Задача
landing_showcase_dealsне запускалась вообще — и не по одной причине, а по двум сразу:scrape_schedulesстроки для неё не было (WHERE source LIKE '%showcase%'— 0 строк, прод 12.09);app/services/product_handlers.pyне было обработчика, то есть строка расписания сама по себе не помогла бы: планировщик заклеймил бы прогон и не нашёл, чем его выполнить.Пересчёт был ручным шагом. На 12.09 публичный лэндинг показывал прогон от 30.08 — тринадцать суток, и признака возраста на странице не было: под таблицей печатались счётчики прогона (
considered/eligible/written), но не его дата.Что в PR
1. Расписание (миграция
303_scrape_schedules_seed_landing_showcase_deals.sql) —enabled=true, окно 06:00–07:00 UTC (после импорта сделок Росреестра 04–06 иlanding_stats_refresh05–06, за два часа доdeals_freshness_monitor08–09),default_params = {"interval_days": 1, "sample": 200, "limit": 20}.Такт суточный не из-за данных: сделки Росреестра приезжают поквартально, по входу хватило бы квартала. Но витрина показывает не сделки, а расхождение прогноза МЕРЫ с ценой сделки, а прогноз считает тот же спайн оценщика, что и боевой расчёт — любая правка оценщика, коэффициентов, набора активных объявлений или правила отбора меняет числа на странице, не трогая ни одной сделки. Сутки — это то, сколько живёт окно «код уже другой, а витрина ещё прежняя».
2. Handler
landing_showcase_dealsвproduct_handlers. Тело задачи писалось под ручнойpython -mи проrun_idне знает, поэтомуmark_done/mark_failedставит обработчик — как уrefresh_search_matview. Параметры берутся из расписания только те, что в нём есть: дефолты остаются в сигнатуре задачи.3. Монитор свежести — существующий, своего не заводим. Сводка просроченных источников (
emit_stale_digest, scraper_kit, #2670) ходит по ВКЛЮЧЁННЫМ расписаниям и пишетlogger.error→ GlitchTip, когда источник не приносил данных дольшеSTALE_DIGEST_INTERVAL_FACTOR(=3) × своего такта. Витрина была невидима для неё не потому, что сводка не умеет про такое говорить, а потому, что источника не существовало. Что сводка жива и считает именно так — видно на проде 12.09 14:25 UTC: «1 источников не собирают дольше 3× своего такта — avito_newbuilding_sweep 3.5d/1d». С суточным тактом порог для витрины = 3 суток, ровно как в приёмке.4. Дата прогона на странице.
computed_atручка/showcaseотдавала и раньше — фронт его не показывал. Теперь подпись под таблицей начинается с него:Даты нет (ручка отдала
null) — предложения нет: «сегодня» на её месте было бы враньём. Форматирование — разбором ISO, неtoLocaleDateString: подпись рендерит серверный компонент, и локальная дата зависела бы от часового пояса контейнера.Тесты (и чем они фальсифицированы)
tradein-mvp/backend/tests/test_3469_showcase_schedule.py(11 проверок) + 2 вlanding-v3-render.test.tsx.enabled=falseв миграцииAssertionError: расписание засеяно выключенными на живой БДassert False is True ... .enabledAssertionError: landing_showcase_deals не резолвится реестром — задача невидимаSTALE_DIGEST_INTERVAL_FACTOR = 3650AssertionError: витрина молчит 3.5 суток при такте 1 и не в тревоге+витрина без прогонов не попала в тревогуUnable to find an element with the text: /Витрина пересчитана 29\.08\.2026/uТретья фальсификация сначала не покраснела: пороги в тестах брались из той же константы кита, которую я и ломал, — ожидание уезжало вместе с ней. Число тактов теперь литерал из приёмки (3), такт по-прежнему читается из миграции.
Живой тест (
test_live_migration_...) применяет миграцию на настоящем Postgres, читает строку обратно, применяет второй раз (дублей нет) и прогоняет НАСТОЯЩИЙ_STALE_SOURCES_SQLсводки. В CI он идёт (ci-tradein.yml поднимает свой Postgres), на машине без базы само-скипается — запись вskip_allowlist.txt.Проверено
pytest tests/ -q→ rc=0, 6054 passed, 36 skippedruff check app tests→ All checks passednpx tsc --noEmit→ rc=0;npx vitest run→ rc=0, 293 passeddocker logs/printenv. Пересчёт витрины руками — решение владельца.Приёмка
landing_showcase_runsпополняется без ручного запуска — после деплоя миграции первый прогон завтра в 06:00 UTC (строкаnext_run_atна завтра, чтобы не стрелять в момент деплоя); проверятьSELECT id, computed_at FROM landing_showcase_runsиscrape_runs WHERE source='landing_showcase_deals'.tradein-scraper→ GlitchTip.🤖 Generated with Claude Code
Правка по ревью (голова
e6e7a8db)Блокер починен. Живой тест применял
015/051/052безусловно, и на схеме, прошедшей всю цепочку (CI и прод), повтор 015 падал:COMMENT ON COLUMN scrape_runs.returning_countв конце файла обращается к колонке, которую снесла 214.CREATE TABLE IF NOT EXISTSпри этом no-op, так что файл идемпотентен относительно себя, но не относительно схемы после 214. На чистой базе этого не видно по построению — ровно поэтому мой прогон был зелёным там, где гонял я, и красным там, где гоняет CI.Теперь зависимости применяются только когда
scrape_schedulesещё нет, а в докстроке теста — рецепт прогона на ПОЛНОЙ схеме (тот же путь, что у CI).Воспроизвёл и проверил тем же путём. Собрал локально полную схему как
ci-tradein.yml(postgis + pg_trgm + роль + все 303 файла подON_ERROR_STOP):1 failed—UndefinedColumn: column "returning_count" ... does not exist11 passed11 passed11 passedДыры из мутаций — закрыты, каждая проверена красным на пересобранной с нуля полной схеме:
6,7→6,23):AssertionError: assert (6, 23) == (6, 7)DELETE+INSERTпри сохранённом текстеON CONFLICT): счёт строк такую замену не ловит — одна строка остаётся и так. Добавил проверку по состоянию:created_atпосле повторного применения обязан совпасть.AssertionError: повторное применение пересоздало строку расписания — на каждом деплое это стирало бы состояние прогонов (last_run_at/next_run_at)_job_landing_stats): сравниваю сам job, а неlog_name.AssertionError: под ключом landing_showcase_deals стоит чужой job: _job_landing_statsnext_run_at > now()— добавлен в живой тест (assert row.next_run_ahead is True), утверждение стояло в приёмке и ничем не проверялось.Замер на голове
e6e7a8db(CI, не локально): все 8 чеков зелёные, включаяCI Trade-In / backend-tests; в логе прогона6092 passedи ни одного skipped — то есть живой тест выполнился, а не само-скипнулся (прошлый прогон на59482caeбыл1 failed, 6091 passed).Локально, для полноты:
pytest tests/ -qна полной схеме → rc=0,6089 passed, 1 skipped(скип —test_pdf_real_render, нет native-либ WeasyPrint);ruff check app tests→ clean.Находку про нулевой прогон (
run_brought_data('done', {...written: 0}) → True: витрина опустеет, блок со страницы исчезнет, сводка промолчит) здесь не чиню — координатор заводит отдельно.