feat(tradein/scheduler): планировщик подхватывает чекпоинт оборванного прогона (#930) #2845
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#2845
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/scheduler-resume-checkpoint"
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?
Что чинится
#930 сделал обе половины механизма — запись точки (
counters.done_buckets, per-bucket heartbeat) и её чтение (run_*_full_load(resume_run_id=...), skip-set в SERP-слое), — но единственным входом оставил админку. У avito full-load админского эндпоинта нет вовсе, а планировщик передавалresume_run_id=Noneлитералом (scheduler.py708 / 728 / 799 наorigin/main). Боевого пути возобновления не существовало ни одного дня.Цена (прод, замер 2026-08-12, read-only, 90 суток): 433 корзины в 30 оборванных прогонах с живой незабранной точкой.
Прогон 3547 (avito_full_load_exhaustive, 09.08, убит деплоем на третьем часу, 35 корзин из 84) лежит до сих пор; расписание 139 подхватит его 16.08 13:37.
Почему не одна строка
resume_run_id=prevОна превратила бы видимую потерю (перескрап) в невидимую. Правка из четырёх частей.
1. Полнота корзины — выражена в коде, а не в комментарии. Ключ
done_buckets(«room:lo:hi») одинаков у целиком и у частично собранной корзины, а частичность возникает тремя путями: страница молча выпала (page_html=None), исключение страницы проглоченоgather(return_exceptions=True), признанный tail-loss / hard-cap. SERP-слой теперь отдаёт признак полноты третьим аргументомon_bucket; пайплайн (_mark_bucket) пишет в чекпоинт только полную корзину, частичные считает вpartial_buckets. У cian это критичнее: в его SERP-слое нет класса блок-исключения вообще, капча посреди корзины приходит какpage_html=None.2. Точка стала монотонной.
update_heartbeat/mark_done/mark_failed/mark_bannedтеперь мержат counters-jsonb вместо полной замены. Раньше_on_progress(после каждой комнатности) и фоновый heartbeat cian'а (каждые 60 с) слалиcounters.to_dict()безdone_bucketsи стирали точку, аmark_failedуничтожал её насовсем: 15 оборванных прогонов с доказанной работой (35 706 + 9 222 fetched, ~45k лотов) лежат на проде вообще без ключа. Это же чинит риск «возобновлённый прогон кончился failed и обнулил накопленное за недели».3. Тождество задания. Кандидат — последний прогон того же source (после
doneвозобновлять нечего),paramsсверяются побайтово (IS NOT DISTINCT FROM):incremental_daysменяет СМЫСЛ ключа (дочитано до watermark ≠ бакет перебран),price_cap_per_bucket— само дерево бисекции. На проде 89 из 433 корзин (21%) лежат в прогонах, чьи params разъехались со следующим — по ним пропуск был бы неверным.4. Наблюдаемость. Вердикт пишется в counters нового прогона (
_pick_resume→update_heartbeat, мерж сохраняет его до финализатора):resume_from,resume_candidate,resume_buckets,resume_chain,resume_reason∈ {ok,no_prev_run,status_*,params_changed,no_checkpoint,checkpoint_stale,chain_limit}. Молчаливый отказ неотличим от отсутствия правки, а docker-логи теряются при редеплое.Плюс
skip_bucketsдоехал до инкрементальной ветки avito — без этого resume в боевом режимеavito_full_load(incremental_days=7) был бы чистым no-op (134 из 242 корзин).Обоснование чисел
interval_days: avito 7, cian 3) — объявленный самим расписанием срок свежести. Сутки — сетка запуска:compute_next_run_atберёт день + случайное время внутри окна, так что соседние запуски отстоят наinterval_days ± <сутки. Без слагаемого точку отвергал бы jitter, а не устаревание: у 3547 к подхвату 16.08 будет 164.6 ч при такте 168 ч — запас 3.4 ч при ширине окна 2 ч. Пропущенный цикл (13 суток для avito) в порог 8 суток всё равно не влезает.STALE_DIGEST_INTERVAL_FACTOR - 1= 2. Не круглое число, а уже существующий в этом файле порог «источник не собирал дольше 3× такта = сломан». Цепочка не имеет права отодвинуть полный обход за ту же черту: прогоны 1 и 2 продолжают предшественника, третий идёт с нуля — то есть попытка полного обхода не реже раза в 21 сутки у avito.reap_zombiesснимает пометкуrunning, но не убивает процесс (listing_source_snapshot.py:24-25), а_claim_runгейтит именно по статусу: подхват читал бы движущуюся точку и запускал второй сборщик на ту же площадку. 147 корзин в 10 прогонах остаются несобранными — это цена, а не недосмотр.Красный прогон
Файл
tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py, 16 тестов.На
origin/main(детач-worktree, тот же файл): 15 failed, 1 passedЕдинственный зелёный на main — контрольная половина параметризации полноты (все три страницы пришли →
complete=True): реализация «всегда False» прошла бы одностороннюю проверку, но убила бы возобновление целиком.На ветке: 16 passed. Полный набор backend-тестов — 3954 passed, те же 12 падений, что и на
origin/main(отсутствующие в локальном venvbcrypt/lxml/matplotlib), регрессий ноль.Прод-проверка SQL (read-only, боевой движок)
Мерж-выражение на боевых counters прогона 3547:
done_buckets35 → 35 при записи от писателя, который о чекпоинте не знает (на main было бы 0).Кто был бы подхвачен, если бы расписания сработали сейчас:
no_checkpoint(403 на page=1, собрано ноль)status_done(дерево обойдено 11.08)Что осознанно НЕ сделано
run_yandex_full_loadи его SERP-слой не трогал: планировщик его не диспатчит (только админка), признак полноты туда не заводил — отдельная задача.app/services/scrape_runs.pyоставлена с прежней семантикой замены: full-load через неё не ходит, а мерж поменял бы счётчики двум десяткам продуктовых джоб без нужды.banned, последний успешный full-load — 03.07. Resume ускоряет прогон, но источник падает не из-за старта с нуля.Test plan
pytest tests/test_930_scheduler_resume_checkpoint.py— 16 passed на ветке, 15 failed на maincounters->>'resume_reason'у новых прогонов full-load; 16.08 подтвердить, что 3547 подхвачен иresume_buckets=35Refs #930
Проверил два главных числа независимо — оба сошлись
1.
mark_failedдействительно стирает точку. Не просто «у failed её нет» — проверил невинное объяснение, что такие прогоны падали рано:Ранним падением это не объясняется. Прогон 263 шёл 104 минуты, собрал 4335 уникальных объявлений, сохранил 3007 — ключа нет. Прогон 356: 83 минуты, 3207 собрано — ключа нет. При этом у
bannedключ есть у 34 из 34, то есть дело именно в путиmark_failed, а не в общем свойстве обрыва.2. Тождество задания: 89 из 433 корзин под разошедшимися params — воспроизвёл ровно, 10 прогонов.
Что понравилось в разборе
Единственный зелёный тест на
origin/main— контрольная половина параметризации полноты. Это ровно та проверка, которой обычно нет: реализация «корзина всегда неполная» прошла бы одностороннее ожидание и молча убила бы возобновление целиком. Двусторонняя параметризация ловит и это.Отдельно верно, что
zombieисключён с названной ценой (147 корзин в 10 прогонах), а не по умолчанию:reap_zombiesснимает пометку, но процесс не убивает, и подхват читал бы движущуюся точку.Почему не мержу прямо сейчас
На проде идут два свипа Яндекса:
Деплой ждёт
scrape_runsдо 5 минут (#1951), а 93-минутный свип в это окно не уложится — мерж сейчас убил бы работу. Ровно это я уже сделал 09.08 с прогоном 3547, который и стал главным примером в этом самом PR.Мержу, когда свипы закончатся. CI зелёный на восьми задачах, возражений по существу нет.
Критерий приёмки, записанный до факта
Расписание 139 (
avito_full_load_exhaustive) подхватит прогон 3547 — 16.08 13:37 UTC. В счётчиках того прогона обязаны появитьсяresume_from=3547иresume_buckets=35.Если появится
resume_reason=checkpoint_stale— значит срок годности (такт + сутки) выбран слишком узко, и это надо пересчитать, а не подкрутить.Если ключей
resume_*не будет вовсе — правка не доехала.