fix(tradein): такт в сохранении расписания, position_in_serp невыразим (#2674) #2694
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#2694
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2674-schedule-save-and-serp"
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?
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.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"interval_days": null→ 1, не TypeErrornext_run_atуважается, в т.ч. в прошлом («запустить сейчас»)tests/test_snapshot_writer.py— колонки нет ни в сигнатуре, ни в SQL-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 — не связано с этим диффомgrep -nE ':[a-z_]+::[a-z]'пуст (psycopg v3 CAST)После мержа
Прод-проверка: сохранить недельный источник через админку и убедиться, что
next_run_atуехал на ~7 суток, а не на завтра.Refs #2674
Две находки из аудита операций, обе — расхождение между тем, что код обещает, и тем, что доезжает до данных. СОХРАНЕНИЕ РАСПИСАНИЯ ТЕРЯЛО ТАКТ 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 #2674save_listingsиз девяти теряютrun_id#2701