fix(tradein): такт в сохранении расписания, position_in_serp невыразим (#2674) #2694

Merged
bot-backend merged 1 commit from fix/2674-schedule-save-and-serp into main 2026-08-06 05:49:48 +00:00
Collaborator

Summary

Две находки из аудита операций #2674. Обе — расхождение между тем, что код обещает, и тем, что доезжает до данных.

1. Сохранение расписания теряло такт источника

PUT /admin/scrape/schedules/{source} звал compute_next_run_at без interval_daysdefault=1next_run_at на завтра, при любом такте. Планировщик такт читал верно (_claim_run/_defer_next_run_at берут его из default_params) — расходились два входа в одну формулу.

Цена на проде: 16 из 50 источников имеют interval_days (3/7/28) — avito_full_load, cian_full_load, rosreestr_quarter_poll (такт 28). Правка окна у любого из них сбивала прогон на завтра. На суточных дефект невидим — для них «завтра» и есть верный ответ, поэтому он дожил до разбора.

Формула лежала двумя побайтово одинаковыми копиями (app/services/scheduler.py и scraper_kit/orchestration/scheduler.py). Копия в app удалена, оставлен re-export: пока файлов два, следующая правка такта разъедется по одному из них.

Заодно next_run_at перестал перезаписываться при каждом сохранении. Комментарий в коде обещал «если window изменился — recompute, иначе keep existing», а момент внутри окна разыгрывался заново даже при правке соседнего поля. Теперь существующий будущий запуск сохраняется, если не менялись ни окно, ни такт — ветвление в ON CONFLICT, без второго round-trip и без гонки.

ScheduleConfigUpdate получил необязательный next_run_at: явная воля оператора уважается как есть, включая момент в прошлом — это «запустить сейчас», которое до сих пор делали ручным UPDATE'ом мимо API.

2. position_in_serp — не оборванная проводка, а невыразимый механизм

upsert_listing_snapshot принимал position_in_serp, ни один из трёх боевых вызывающих его не передавал, колонка пуста 0 из 395 240 строк.

Соблазнительный вывод «парсер выдачи знает индекс карточки, подключите» — неверен. Позиция есть свойство пары (объявление, конкретный прогон выдачи с конкретными фильтрами), а PRIMARY KEY здесь — (listing_id, snapshot_date): одна строка на объявление в сутки, run_id лишь атрибут под COALESCE.

  • за 2026-08-05 в таблицу писали 77 прогонов; за 2026-08-03 — 13 разных run_id на одну дату, причём четыре SERP-источника сразу (yandex/cian/avito/domclick city_sweep);
  • внутри одного city_sweep обход идёт по десяткам гео-якорей радиусом 1500 м с перекрытием, и save_listings получает anchor_lots (3 страницы ОДНОГО якоря), а не общий ранжированный список — одно объявление приезжает с разным индексом от разных якорей того же прогона;
  • старый ON CONFLICT писал COALESCE(EXCLUDED, старое) → в строке осел бы индекс последнего писателя дня. Не «позиция», а произвольный представитель суток. Хуже NULL, потому что читалось бы как факт.

Читателей у колонки нет: 0 view/matview на проде ссылаются на неё.

Почему DROP не в этом PR. deploy-tradein.yml применяет data/sql/*.sql ДО перезапуска контейнеров («(3) Применяем SQL миграции — ДО app»). DROP в одном деплое с правкой кода оставил бы окно в несколько минут, где ещё живой СТАРЫЙ образ делает INSERT со списком колонок, включающим удалённую. Запись снэпшотов fault-tolerant (SAVEPOINT + warning) — упало бы тихо, ровно тот класс потерь, который замечают через недели по дырке в истории цен. Миграция 217 пока только подписывает колонку и объясняет диагноз; DROP — отдельным PR после того как этот образ живёт на проде.

Test plan

  • test_weekly_source_gets_next_run_in_a_week_not_tomorrow падает на старом коде: ожидали ~7 суток, получили 1.56
  • back-compat: такт 1 и отсутствие ключа → прежнее «завтра»; "interval_days": null → 1, не TypeError
  • явный next_run_at уважается, в т.ч. в прошлом («запустить сейчас»)
  • tests/test_snapshot_writer.py — колонки нет ни в сигнатуре, ни в SQL
  • 271 passed в -k "scheduler or snapshot or schedule or 2674 or scraper_kit"; полный прогон 3184 passed при одном ПРЕД-СУЩЕСТВУЮЩЕМ падении test_search_api.py::test_search_cache_hit (401), воспроизведено на чистом origin/main в отдельном worktree — не связано с этим диффом
  • pre-commit (ruff + ruff-format) зелёный; grep -nE ':[a-z_]+::[a-z]' пуст (psycopg v3 CAST)

После мержа

Прод-проверка: сохранить недельный источник через админку и убедиться, что next_run_at уехал на ~7 суток, а не на завтра.

Refs #2674

## Summary Две находки из аудита операций #2674. Обе — расхождение между тем, что код обещает, и тем, что доезжает до данных. ### 1. Сохранение расписания теряло такт источника `PUT /admin/scrape/schedules/{source}` звал `compute_next_run_at` **без** `interval_days` → `default=1` → `next_run_at` на завтра, при любом такте. Планировщик такт читал верно (`_claim_run`/`_defer_next_run_at` берут его из `default_params`) — расходились два входа в одну формулу. Цена на проде: **16 из 50** источников имеют `interval_days` (3/7/28) — `avito_full_load`, `cian_full_load`, `rosreestr_quarter_poll` (такт 28). Правка окна у любого из них сбивала прогон на завтра. На суточных дефект невидим — для них «завтра» и есть верный ответ, поэтому он дожил до разбора. Формула лежала **двумя побайтово одинаковыми копиями** (`app/services/scheduler.py` и `scraper_kit/orchestration/scheduler.py`). Копия в `app` удалена, оставлен re-export: пока файлов два, следующая правка такта разъедется по одному из них. Заодно `next_run_at` перестал перезаписываться при каждом сохранении. Комментарий в коде обещал «если window изменился — recompute, иначе keep existing», а момент внутри окна разыгрывался заново даже при правке соседнего поля. Теперь существующий будущий запуск сохраняется, если не менялись ни окно, ни такт — ветвление в `ON CONFLICT`, без второго round-trip и без гонки. `ScheduleConfigUpdate` получил необязательный `next_run_at`: явная воля оператора уважается как есть, включая момент в прошлом — это «запустить сейчас», которое до сих пор делали ручным `UPDATE`'ом мимо API. ### 2. position_in_serp — не оборванная проводка, а невыразимый механизм `upsert_listing_snapshot` принимал `position_in_serp`, ни один из трёх боевых вызывающих его не передавал, колонка пуста **0 из 395 240** строк. Соблазнительный вывод «парсер выдачи знает индекс карточки, подключите» — неверен. Позиция есть свойство пары (объявление, конкретный прогон выдачи с конкретными фильтрами), а `PRIMARY KEY` здесь — `(listing_id, snapshot_date)`: одна строка на объявление в **сутки**, `run_id` лишь атрибут под `COALESCE`. - за 2026-08-05 в таблицу писали 77 прогонов; за 2026-08-03 — 13 разных `run_id` на одну дату, причём четыре SERP-источника сразу (yandex/cian/avito/domclick city_sweep); - внутри одного `city_sweep` обход идёт по десяткам гео-якорей радиусом 1500 м **с перекрытием**, и `save_listings` получает `anchor_lots` (3 страницы ОДНОГО якоря), а не общий ранжированный список — одно объявление приезжает с разным индексом от разных якорей того же прогона; - старый `ON CONFLICT` писал `COALESCE(EXCLUDED, старое)` → в строке осел бы индекс последнего писателя дня. Не «позиция», а произвольный представитель суток. Хуже NULL, потому что читалось бы как факт. Читателей у колонки нет: 0 view/matview на проде ссылаются на неё. **Почему DROP не в этом PR.** `deploy-tradein.yml` применяет `data/sql/*.sql` ДО перезапуска контейнеров («(3) Применяем SQL миграции — ДО app»). DROP в одном деплое с правкой кода оставил бы окно в несколько минут, где ещё живой СТАРЫЙ образ делает `INSERT` со списком колонок, включающим удалённую. Запись снэпшотов fault-tolerant (SAVEPOINT + warning) — упало бы тихо, ровно тот класс потерь, который замечают через недели по дырке в истории цен. Миграция 217 пока только подписывает колонку и объясняет диагноз; DROP — отдельным PR после того как этот образ живёт на проде. ## Test plan - [x] `test_weekly_source_gets_next_run_in_a_week_not_tomorrow` **падает на старом коде**: `ожидали ~7 суток, получили 1.56` - [x] back-compat: такт 1 и отсутствие ключа → прежнее «завтра»; `"interval_days": null` → 1, не TypeError - [x] явный `next_run_at` уважается, в т.ч. в прошлом («запустить сейчас») - [x] `tests/test_snapshot_writer.py` — колонки нет ни в сигнатуре, ни в SQL - [x] 271 passed в `-k "scheduler or snapshot or schedule or 2674 or scraper_kit"`; полный прогон 3184 passed при одном ПРЕД-СУЩЕСТВУЮЩЕМ падении `test_search_api.py::test_search_cache_hit` (401), воспроизведено на чистом `origin/main` в отдельном worktree — не связано с этим диффом - [x] pre-commit (ruff + ruff-format) зелёный; `grep -nE ':[a-z_]+::[a-z]'` пуст (psycopg v3 CAST) ## После мержа Прод-проверка: сохранить недельный источник через админку и убедиться, что `next_run_at` уехал на ~7 суток, а не на завтра. Refs #2674
bot-backend added 1 commit 2026-08-06 05:46:12 +00:00
fix(tradein): такт в сохранении расписания, position_in_serp невыразим (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
CI Trade-In / backend-tests (pull_request) Successful in 3m1s
5294b452af
Две находки из аудита операций, обе — расхождение между тем, что код обещает,
и тем, что доезжает до данных.

СОХРАНЕНИЕ РАСПИСАНИЯ ТЕРЯЛО ТАКТ

PUT /admin/scrape/schedules/{source} звал compute_next_run_at БЕЗ interval_days,
получал default=1 и ставил next_run_at на завтра — при любом такте источника.
Планировщик такт читал верно (_claim_run/_defer_next_run_at берут его из
default_params), так что расходились два входа в одну формулу: оператор правит
окно недельного avito_full_load — и источник, которому положено бежать раз в 7
суток, побежит завтра. На проде таких источников 16 из 50 (interval_days 3/7/28,
включая avito_full_load, cian_full_load, rosreestr_quarter_poll с тактом 28).
На суточных дефект невидим: для них «завтра» и есть верный ответ — поэтому он и
дожил до разбора.

Формула лежала ДВУМЯ побайтово одинаковыми копиями (app/services/scheduler.py и
scraper_kit/orchestration/scheduler.py). Копия в app удалена, оставлен re-export:
пока файлов два, следующая правка такта разъедется по одному из них.

Заодно next_run_at перестал перезаписываться при каждом сохранении. Комментарий в
коде обещал «если window изменился — recompute, иначе keep existing», а на деле
момент внутри окна разыгрывался заново даже при правке соседнего поля. Теперь
существующий будущий запуск сохраняется, если не менялись ни окно, ни такт;
ветвление в ON CONFLICT, без второго round-trip и без гонки.

ScheduleConfigUpdate получил необязательный next_run_at: явная воля оператора
уважается как есть, включая момент в прошлом — это «запустить сейчас», которое до
сих пор делали ручным UPDATE'ом мимо API.

Тест на такт 7 падает на старом коде (1.56 суток вместо ~7).

POSITION_IN_SERP — НЕ ПРОВОДКА, А НЕВЫРАЗИМЫЙ МЕХАНИЗМ

upsert_listing_snapshot принимал position_in_serp, ни один из трёх боевых
вызывающих его не передавал, колонка пуста 0 из 395 240 строк. Соблазнительный
вывод — «парсер выдачи знает индекс карточки, подключите» — неверен.

Позиция есть свойство пары (объявление, конкретный прогон выдачи с конкретными
фильтрами). PRIMARY KEY здесь — (listing_id, snapshot_date): одна строка на
объявление в СУТКИ, run_id лишь атрибут под COALESCE. За 2026-08-05 в таблицу
писали 77 прогонов, за 2026-08-03 — 13 разных run_id на одну дату, причём четыре
SERP-источника сразу. Внутри одного city_sweep обход идёт по десяткам гео-якорей
радиусом 1500 м с перекрытием, и save_listings получает anchor_lots (3 страницы
ОДНОГО якоря), а не общий ранжированный список — одно объявление приезжает с
разным индексом от разных якорей того же прогона. Старый ON CONFLICT писал
COALESCE(EXCLUDED, старое), то есть в строке осел бы индекс последнего писателя
дня: не «позиция», а произвольный представитель суток. Хуже NULL — читалось бы
как факт.

Параметр и упоминания колонки убраны из писателя. Сама колонка дропается ОТДЕЛЬНОЙ
миграцией после того, как этот образ доедет до прода: deploy-tradein.yml применяет
data/sql ДО перезапуска контейнеров, поэтому DROP в одном деплое с правкой кода
оставил бы окно, где ещё живой старый образ делает INSERT с удалённой колонкой.
Запись снэпшотов fault-tolerant (SAVEPOINT + warning) — упало бы тихо. Миграция
217 пока только подписывает колонку и объясняет диагноз.

Refs #2674
bot-backend merged commit 27272762ef into main 2026-08-06 05:49:48 +00:00
bot-backend deleted branch fix/2674-schedule-save-and-serp 2026-08-06 05:49:49 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2694
No description provided.