fix(mera): витрина сделок лэндинга — в расписание, дата прогона на страницу, монитор свежести (#3469) #3509

Merged
bot-backend merged 2 commits from fix/3469-showcase-schedule into main 2026-09-12 17:12:30 +00:00
Collaborator

Закрывает #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_refresh 05–06, за два часа до deals_freshness_monitor 08–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 отдавала и раньше — фронт его не показывал. Теперь подпись под таблицей начинается с него:

Витрина пересчитана 30.08.2026. Показано 20 строк из 161 собранных прогоном, рассмотрено сделок: 200. Район известен у 17 из показанных. <правило отбора>

Даты нет (ручка отдала 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 ... .enabled
убрал ключ из реестра handler'ов AssertionError: landing_showcase_deals не резолвится реестром — задача невидима
STALE_DIGEST_INTERVAL_FACTOR = 3650 AssertionError: витрина молчит 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 skipped
  • ruff check app tests → All checks passed
  • npx tsc --noEmit → rc=0; npx vitest run → rc=0, 293 passed
  • На прод ничего не писал: только SELECT/docker 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'.
  • На странице видна дата последнего пересчёта — под таблицей блока «Точность».
  • Отсутствие прогона дольше 3× такта (3 суток) даёт тревогу — ERROR сводки в tradein-scraper → GlitchTip.

🤖 Generated with Claude Code

Закрывает #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_refresh` 05–06, за два часа до `deals_freshness_monitor` 08–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` отдавала и раньше — фронт его не показывал. Теперь подпись под таблицей начинается с него: > Витрина пересчитана 30.08.2026. Показано 20 строк из 161 собранных прогоном, рассмотрено сделок: 200. Район известен у 17 из показанных. <правило отбора> Даты нет (ручка отдала `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 ... .enabled` | | убрал ключ из реестра handler'ов | `AssertionError: landing_showcase_deals не резолвится реестром — задача невидима` | | `STALE_DIGEST_INTERVAL_FACTOR = 3650` | `AssertionError: витрина молчит 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 skipped - `ruff check app tests` → All checks passed - `npx tsc --noEmit` → rc=0; `npx vitest run` → rc=0, 293 passed - На прод ничего не писал: только SELECT/`docker 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'`. - На странице видна дата последнего пересчёта — под таблицей блока «Точность». - Отсутствие прогона дольше 3× такта (3 суток) даёт тревогу — ERROR сводки в `tradein-scraper` → GlitchTip. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-12 15:06:44 +00:00
fix(mera): витрина сделок в расписании, дата прогона на странице (#3469)
Some checks failed
CI Trade-In / frontend-checks (pull_request) Successful in 1m57s
CI Trade-In / backend-tests (pull_request) Failing after 5m27s
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
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
59482cae85
Задача landing_showcase_deals не запускалась вообще: строки в
scrape_schedules не было (0 строк по '%showcase%' на проде 12.09), а в
реестре product_handlers — обработчика. Пересчёт был ручным шагом, и
лэндинг показывал прогон от 30.08 — тринадцать суток.

Что сделано:

- Handler `landing_showcase_deals` в product_handlers: тело задачи
  писалось под `python -m` и про run_id не знает, поэтому done/failed
  ставит обработчик (как у refresh_search_matview).
- Миграция 303 сеет расписание: enabled=true, окно 06:00–07:00 UTC,
  interval_days=1. Такт суточный не из-за данных — сделки Росреестра
  квартальные, — а из-за кода: прогноз считает тот же спайн оценщика,
  что и боевой расчёт, и любой деплой меняет числа на витрине, не
  трогая ни одной сделки.
- Эта же строка заводит витрину в СУЩЕСТВУЮЩИЙ монитор свежести:
  сводка просроченных источников (emit_stale_digest, #2670) ходит по
  включённым расписаниям и бьёт ERROR → GlitchTip, когда источник
  молчит дольше 3× своего такта. Своего монитора не заводим: витрина
  была невидима не потому, что сводка не умеет про неё говорить, а
  потому, что источника для сводки не существовало.
- На странице под таблицей — дата прогона рядом со счётчиками:
  «Витрина пересчитана 30.08.2026». computed_at ручка /showcase отдавала
  и раньше, фронт его не показывал; даты нет — предложения нет.

Тесты: обработчик резолвится тем же resolve_handler, что и боевой
_dispatch; миграция на живой БД реально кладёт строку и не задваивает
её при повторе; настоящий запрос сводки видит витрину и отдаёт её
просроченной после 3× такта (число тактов — литерал из приёмки, не
константа кита: взятое из неё ожидание уезжало вместе с ней и держало
тесты зелёными при факторе 3650).

Closes #3469

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-12 16:29:57 +00:00
fix(mera): живой тест витрины гоняется на полной схеме, а не только на пустой (#3469)
All checks were successful
CI / changes (pull_request) Successful in 12s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m17s
e6e7a8db1c
Тест применял 015/051/052 безусловно, и на базе, прошедшей всю цепочку
миграций (CI и прод), повтор 015 падал:

  psycopg.errors.UndefinedColumn: column "returning_count" of relation
  "scrape_runs" does not exist

Файл 015 идемпотентен относительно себя, но не относительно схемы,
прошедшей 214 (DROP COLUMN IF EXISTS returning_count): CREATE TABLE IF
NOT EXISTS — no-op, а COMMENT ON COLUMN в конце того же файла обращается
к снесённой колонке. На чистой базе, где 015 ложится с нуля, этого не
видно по построению — потому прогон и был зелёным там, где его гонял я,
и красным там, где его гоняет CI.

Зависимости теперь применяются только когда scrape_schedules ещё нет; в
докстроке — рецепт прогона на ПОЛНОЙ схеме, тем же путём, что у CI.

Заодно закрыты три дыры, которые находились мутациями:

- окно расписания и повторное применение проверяются на живой БД (до
  этого 6,7 → 6,23 и DELETE+INSERT вместо ON CONFLICT проходили насквозь);
  идемпотентность меряется created_at строки, а не числом строк — замена
  «удалить и вставить» тоже оставляет ровно одну строку, но стирает
  last_run_at/next_run_at на каждом деплое;
- next_run_at в будущем — утверждение стояло в приёмке и ничем не
  проверялось;
- handler сравнивается по САМОМУ job'у, а не по log_name: имя — второй
  литерал конструктора Handler, и чужое тело под верным ключом
  (_job_landing_stats) проходило проверку по имени.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

Правка по ревью (голова 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):

база до правки после
полная схема (путь CI) 1 failedUndefinedColumn: column "returning_count" ... does not exist 11 passed
чистая 11 passed 11 passed

Дыры из мутаций — закрыты, каждая проверена красным на пересобранной с нуля полной схеме:

  • M2 (окно 6,76,23): AssertionError: assert (6, 23) == (6, 7)
  • M3 (DELETE + INSERT при сохранённом тексте ON CONFLICT): счёт строк такую замену не ловит — одна строка остаётся и так. Добавил проверку по состоянию: created_at после повторного применения обязан совпасть. AssertionError: повторное применение пересоздало строку расписания — на каждом деплое это стирало бы состояние прогонов (last_run_at/next_run_at)
  • M6 (верный ключ, чужой job _job_landing_stats): сравниваю сам job, а не log_name. AssertionError: под ключом landing_showcase_deals стоит чужой job: _job_landing_stats

next_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: витрина опустеет, блок со страницы исчезнет, сводка промолчит) здесь не чиню — координатор заводит отдельно.

### Правка по ревью (голова `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`): | база | до правки | после | |---|---|---| | полная схема (путь CI) | `1 failed` — `UndefinedColumn: column "returning_count" ... does not exist` | `11 passed` | | чистая | `11 passed` | `11 passed` | **Дыры из мутаций — закрыты, каждая проверена красным на пересобранной с нуля полной схеме:** - **M2** (окно `6,7` → `6,23`): `AssertionError: assert (6, 23) == (6, 7)` - **M3** (`DELETE` + `INSERT` при сохранённом тексте `ON CONFLICT`): счёт строк такую замену не ловит — одна строка остаётся и так. Добавил проверку по состоянию: `created_at` после повторного применения обязан совпасть. `AssertionError: повторное применение пересоздало строку расписания — на каждом деплое это стирало бы состояние прогонов (last_run_at/next_run_at)` - **M6** (верный ключ, чужой job `_job_landing_stats`): сравниваю сам job, а не `log_name`. `AssertionError: под ключом landing_showcase_deals стоит чужой job: _job_landing_stats` **`next_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`: витрина опустеет, блок со страницы исчезнет, сводка промолчит) здесь не чиню — координатор заводит отдельно.
bot-backend merged commit 8f06373e2f into main 2026-09-12 17:12:30 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3509
No description provided.