fix(tradein): такт в сохранении расписания, position_in_serp невыразим (#2674) #2694
Merged
bot-backend
merged 1 commit from 2026-08-06 05:49:48 +00:00
fix/2674-schedule-save-and-serp into main
1 commit
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 5294b452af |
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
Две находки из аудита операций, обе — расхождение между тем, что код обещает,
и тем, что доезжает до данных.
СОХРАНЕНИЕ РАСПИСАНИЯ ТЕРЯЛО ТАКТ
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
|