feat(tradein/scheduler): планировщик подхватывает чекпоинт оборванного прогона (#930) #2845

Merged
bot-backend merged 1 commit from fix/scheduler-resume-checkpoint into main 2026-08-12 18:51:03 +00:00
Collaborator

Что чинится

#930 сделал обе половины механизма — запись точки (counters.done_buckets, per-bucket heartbeat) и её чтение (run_*_full_load(resume_run_id=...), skip-set в SERP-слое), — но единственным входом оставил админку. У avito full-load админского эндпоинта нет вовсе, а планировщик передавал resume_run_id=None литералом (scheduler.py 708 / 728 / 799 на origin/main). Боевого пути возобновления не существовало ни одного дня.

Цена (прод, замер 2026-08-12, read-only, 90 суток): 433 корзины в 30 оборванных прогонах с живой незабранной точкой.

источник оборвано корзин впустую
avito_full_load 20 242
cian_full_load 7 134
avito_full_load_exhaustive 3 57

Прогон 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_resumeupdate_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.
  • zombie исключён намеренно. reap_zombies снимает пометку running, но не убивает процесс (listing_source_snapshot.py:24-25), а _claim_run гейтит именно по статусу: подхват читал бы движущуюся точку и запускал второй сборщик на ту же площадку. 147 корзин в 10 прогонах остаются несобранными — это цена, а не недосмотр.
  • failed включён, потому что после п.2 его чекпоинт больше не стирается, а «наш баг» ничего не говорит о полноте уже записанных корзин.

Красный прогон

Файл tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py, 16 тестов.

На origin/main (детач-worktree, тот же файл): 15 failed, 1 passed

assert captured["resume_run_id"] == 3547
E       assert None == 3547            ← ×3 (avito / exhaustive / cian)
assert [c[1] for c in calls] == [expected_complete]
E       assert [True] == [False]       ← корзина с выпавшей страницей приезжает «сделанной»
E       TypeError: _on_bucket() takes 2 positional arguments but 3 were given

Единственный зелёный на main — контрольная половина параметризации полноты (все три страницы пришли → complete=True): реализация «всегда False» прошла бы одностороннюю проверку, но убила бы возобновление целиком.

На ветке: 16 passed. Полный набор backend-тестов — 3954 passed, те же 12 падений, что и на origin/main (отсутствующие в локальном venv bcrypt/lxml/matplotlib), регрессий ноль.

Прод-проверка SQL (read-only, боевой движок)

Мерж-выражение на боевых counters прогона 3547: done_buckets 35 → 35 при записи от писателя, который о чекпоинте не знает (на main было бы 0).

Кто был бы подхвачен, если бы расписания сработали сейчас:

source кандидат статус params возраст корзин вердикт
avito_full_load 3629 banned совпали 50.3 ч 0 no_checkpoint (403 на page=1, собрано ноль)
avito_full_load_exhaustive 3547 cancelled совпали 71.7 ч 35 подхват
cian_full_load 3723 done совпали 13.3 ч 70 status_done (дерево обойдено 11.08)

Что осознанно НЕ сделано

  • run_yandex_full_load и его SERP-слой не трогал: планировщик его не диспатчит (только админка), признак полноты туда не заводил — отдельная задача.
  • Копия app/services/scrape_runs.py оставлена с прежней семантикой замены: full-load через неё не ходит, а мерж поменял бы счётчики двум десяткам продуктовых джоб без нужды.
  • Возобновление не лечит причину банов: у avito 22 из 22 прогонов за 30 суток кончились banned, последний успешный full-load — 03.07. Resume ускоряет прогон, но источник падает не из-за старта с нуля.

Test plan

  • pytest tests/test_930_scheduler_resume_checkpoint.py — 16 passed на ветке, 15 failed на main
  • полный backend-suite: регрессий нет (12 падений совпадают с main)
  • SQL проверен на боевом Postgres (SELECT'ами, без записи)
  • после мержа: смотреть counters->>'resume_reason' у новых прогонов full-load; 16.08 подтвердить, что 3547 подхвачен и resume_buckets=35

Refs #930

## Что чинится #930 сделал **обе половины** механизма — запись точки (`counters.done_buckets`, per-bucket heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), — но единственным входом оставил админку. У avito full-load админского эндпоинта **нет вовсе**, а планировщик передавал `resume_run_id=None` **литералом** (`scheduler.py` 708 / 728 / 799 на `origin/main`). Боевого пути возобновления не существовало ни одного дня. Цена (прод, замер 2026-08-12, read-only, 90 суток): **433 корзины в 30 оборванных прогонах** с живой незабранной точкой. | источник | оборвано | корзин впустую | |---|---|---| | avito_full_load | 20 | 242 | | cian_full_load | 7 | 134 | | avito_full_load_exhaustive | 3 | 57 | Прогон **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. - **zombie исключён намеренно.** `reap_zombies` снимает пометку `running`, но не убивает процесс (`listing_source_snapshot.py:24-25`), а `_claim_run` гейтит именно по статусу: подхват читал бы **движущуюся** точку и запускал второй сборщик на ту же площадку. 147 корзин в 10 прогонах остаются несобранными — это цена, а не недосмотр. - **failed включён**, потому что после п.2 его чекпоинт больше не стирается, а «наш баг» ничего не говорит о полноте уже записанных корзин. ## Красный прогон Файл `tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py`, 16 тестов. На `origin/main` (детач-worktree, тот же файл): **15 failed, 1 passed** ``` assert captured["resume_run_id"] == 3547 E assert None == 3547 ← ×3 (avito / exhaustive / cian) assert [c[1] for c in calls] == [expected_complete] E assert [True] == [False] ← корзина с выпавшей страницей приезжает «сделанной» E TypeError: _on_bucket() takes 2 positional arguments but 3 were given ``` Единственный зелёный на main — **контрольная** половина параметризации полноты (все три страницы пришли → `complete=True`): реализация «всегда False» прошла бы одностороннюю проверку, но убила бы возобновление целиком. На ветке: **16 passed**. Полный набор backend-тестов — 3954 passed, те же 12 падений, что и на `origin/main` (отсутствующие в локальном venv `bcrypt`/`lxml`/`matplotlib`), регрессий ноль. ## Прод-проверка SQL (read-only, боевой движок) Мерж-выражение на боевых counters прогона 3547: `done_buckets` 35 → 35 при записи от писателя, который о чекпоинте не знает (на main было бы 0). Кто был бы подхвачен, если бы расписания сработали сейчас: | source | кандидат | статус | params | возраст | корзин | вердикт | |---|---|---|---|---|---|---| | avito_full_load | 3629 | banned | совпали | 50.3 ч | 0 | `no_checkpoint` (403 на page=1, собрано ноль) | | avito_full_load_exhaustive | 3547 | cancelled | совпали | 71.7 ч | 35 | **подхват** | | cian_full_load | 3723 | done | совпали | 13.3 ч | 70 | `status_done` (дерево обойдено 11.08) | ## Что осознанно НЕ сделано - `run_yandex_full_load` и его SERP-слой не трогал: планировщик его не диспатчит (только админка), признак полноты туда не заводил — отдельная задача. - Копия `app/services/scrape_runs.py` оставлена с прежней семантикой замены: full-load через неё не ходит, а мерж поменял бы счётчики двум десяткам продуктовых джоб без нужды. - Возобновление не лечит причину банов: у avito 22 из 22 прогонов за 30 суток кончились `banned`, последний успешный full-load — 03.07. Resume ускоряет прогон, но источник падает не из-за старта с нуля. ## Test plan - [x] `pytest tests/test_930_scheduler_resume_checkpoint.py` — 16 passed на ветке, 15 failed на main - [x] полный backend-suite: регрессий нет (12 падений совпадают с main) - [x] SQL проверен на боевом Postgres (SELECT'ами, без записи) - [ ] после мержа: смотреть `counters->>'resume_reason'` у новых прогонов full-load; 16.08 подтвердить, что 3547 подхвачен и `resume_buckets=35` Refs #930
bot-backend added 1 commit 2026-08-12 16:41:46 +00:00
feat(tradein/scheduler): планировщик подхватывает чекпоинт оборванного прогона (#930)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (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 4m24s
CI / backend-tests (pull_request) Has been skipped
d9b4f124a6
#930 сделал обе половины механизма — запись точки (counters.done_buckets) и её
чтение (run_*_full_load(resume_run_id=...)) — но единственным входом оставил
админку. У avito full-load её нет вовсе, а планировщик передавал resume_run_id
литеральным None. То есть боевого пути возобновления не существовало ни дня:
433 корзины в 30 оборванных прогонах за 90 суток (avito 242, cian 134,
exhaustive 57) перебирались заново, включая 35 корзин прогона 3547, убитого
деплоем на третьем часу и лежащего с 09.08.

Одной строки resume_run_id=prev было бы мало и опасно — правка из четырёх частей.

1. Полнота корзины (иначе видимая потеря стала бы невидимой). Ключ
   done_buckets одинаков у целиком и частично собранной корзины, а частичность
   возникает тремя путями: страница молча выпала (page_html=None), исключение
   страницы проглочено gather'ом, признанный tail-loss/hard-cap. SERP-слой
   теперь отдаёт признак полноты третьим аргументом on_bucket, пайплайн пишет
   в чекпоинт ТОЛЬКО полную корзину, частичные считает (partial_buckets).

2. Точка стала монотонной. update_heartbeat/mark_* мержат counters-jsonb
   вместо замены: раньше _on_progress и фоновый heartbeat cian'а (каждые 60 с)
   слали counters без done_buckets и СТИРАЛИ точку, а mark_failed уничтожал её
   насовсем — 15 оборванных прогонов с 45k собранных лотов и без ключа вовсе.

3. Тождество задания. Кандидат — ПОСЛЕДНИЙ прогон того же source (после 'done'
   возобновлять нечего), params сверяются побайтово: incremental_days меняет
   СМЫСЛ ключа, price_cap_per_bucket — само дерево бисекции. Статусы banned/
   cancelled/failed; zombie исключён намеренно (reap не убивает процесс,
   точка может двигаться). Срок годности — такт источника плюс сутки сетки
   запуска. Цепочка возобновлений ограничена STALE_DIGEST_INTERVAL_FACTOR-1.

4. Наблюдаемость. Вердикт (resume_from/resume_reason/resume_buckets/
   resume_chain) пишется в counters нового прогона: no_prev_run, status_*,
   params_changed, no_checkpoint, checkpoint_stale, chain_limit, ok.

Плюс skip_buckets доехал до инкрементальной ветки avito — без этого resume в
боевом режиме avito_full_load (incremental_days=7) был бы чистым no-op.
Author
Collaborator

Проверил два главных числа независимо — оба сошлись

1. mark_failed действительно стирает точку. Не просто «у failed её нет» — проверил невинное объяснение, что такие прогоны падали рано:

статус прогонов точка есть ключа НЕТ дольше 20 мин
banned 34 34 0 6
cancelled 22 12 6 11
failed 20 0 20 8
zombie 11 10 1 9

Ранним падением это не объясняется. Прогон 263 шёл 104 минуты, собрал 4335 уникальных объявлений, сохранил 3007 — ключа нет. Прогон 356: 83 минуты, 3207 собрано — ключа нет. При этом у banned ключ есть у 34 из 34, то есть дело именно в пути mark_failed, а не в общем свойстве обрыва.

2. Тождество задания: 89 из 433 корзин под разошедшимися params — воспроизвёл ровно, 10 прогонов.

Что понравилось в разборе

Единственный зелёный тест на origin/mainконтрольная половина параметризации полноты. Это ровно та проверка, которой обычно нет: реализация «корзина всегда неполная» прошла бы одностороннее ожидание и молча убила бы возобновление целиком. Двусторонняя параметризация ловит и это.

Отдельно верно, что zombie исключён с названной ценой (147 корзин в 10 прогонах), а не по умолчанию: reap_zombies снимает пометку, но процесс не убивает, и подхват читал бы движущуюся точку.

Почему не мержу прямо сейчас

На проде идут два свипа Яндекса:

3798  yandex_city_sweep                    93 минуты, 1615 лотов собрано, 1513 обновлено
3805  yandex_city_sweep_verkhnyaya_pyshma  только стартовал

Деплой ждёт scrape_runs до 5 минут (#1951), а 93-минутный свип в это окно не уложится — мерж сейчас убил бы работу. Ровно это я уже сделал 09.08 с прогоном 3547, который и стал главным примером в этом самом PR.

Мержу, когда свипы закончатся. CI зелёный на восьми задачах, возражений по существу нет.

Критерий приёмки, записанный до факта

Расписание 139 (avito_full_load_exhaustive) подхватит прогон 354716.08 13:37 UTC. В счётчиках того прогона обязаны появиться resume_from=3547 и resume_buckets=35.

Если появится resume_reason=checkpoint_stale — значит срок годности (такт + сутки) выбран слишком узко, и это надо пересчитать, а не подкрутить.
Если ключей resume_* не будет вовсе — правка не доехала.

## Проверил два главных числа независимо — оба сошлись **1. `mark_failed` действительно стирает точку.** Не просто «у failed её нет» — проверил невинное объяснение, что такие прогоны падали рано: | статус | прогонов | точка есть | ключа НЕТ | дольше 20 мин | |---|---|---|---|---| | banned | 34 | **34** | 0 | 6 | | cancelled | 22 | 12 | 6 | 11 | | failed | 20 | **0** | **20** | 8 | | zombie | 11 | 10 | 1 | 9 | Ранним падением это не объясняется. Прогон 263 шёл **104 минуты**, собрал 4335 уникальных объявлений, сохранил 3007 — ключа нет. Прогон 356: 83 минуты, 3207 собрано — ключа нет. При этом у `banned` ключ есть у 34 из 34, то есть дело именно в пути `mark_failed`, а не в общем свойстве обрыва. **2. Тождество задания: 89 из 433 корзин** под разошедшимися params — воспроизвёл ровно, 10 прогонов. ## Что понравилось в разборе Единственный зелёный тест на `origin/main` — **контрольная** половина параметризации полноты. Это ровно та проверка, которой обычно нет: реализация «корзина всегда неполная» прошла бы одностороннее ожидание и молча убила бы возобновление целиком. Двусторонняя параметризация ловит и это. Отдельно верно, что `zombie` исключён **с названной ценой** (147 корзин в 10 прогонах), а не по умолчанию: `reap_zombies` снимает пометку, но процесс не убивает, и подхват читал бы движущуюся точку. ## Почему не мержу прямо сейчас На проде идут два свипа Яндекса: ``` 3798 yandex_city_sweep 93 минуты, 1615 лотов собрано, 1513 обновлено 3805 yandex_city_sweep_verkhnyaya_pyshma только стартовал ``` Деплой ждёт `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_*` не будет вовсе — правка не доехала.
bot-backend merged commit 1ff6699b95 into main 2026-08-12 18:51:03 +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#2845
No description provided.