feat(tradein): чекпоинты #3074 — наследование при claim + yandex_city_sweep #3098

Merged
bot-backend merged 2 commits from fix/3074-checkpoint-survives-claim into main 2026-08-26 07:49:29 +00:00
Collaborator

Два шарда #3074 (по коммиту на каждый).

1. Чекпоинт переживает обрыв до первой новой корзины

Прод-факт: прогон 4707 (23.08) подхватил 42 корзины у 4117, был убит деплоем на 26-й минуте до завершения первой НОВОЙ корзины — и не успел ни разу написать heartbeat с done_buckets. Его чекпоинт пуст → следующий кандидат увидел бы no_checkpoint, цепочка оборвалась бы. Фикс: _resume_decision при ok кладёт done_buckets предшественника в counters-заготовку нового прогона (персистится при claim; heartbeat мержит jsonb — первый bucket-heartbeat перезапишет надмножеством). Отказные вердикты чекпоинт не наследуют.

2. Чекпоинты для yandex_city_sweep

Из таблицы убитых деплоем: 15.08 — 65 мин, 12.08 — 2ч33м, оба потеряны целиком (у свипа чекпоинтов не было вовсе). Единица — combo (сегмент × комнатность × диапазон цены), ровно то, чем цикл уже итерируется.

  • провайдер fetch_around_multi_room: skip_combos (ни одного HTTP по собранным) + on_combo для каждого ПРОЙДЕННОГО combo, включая пустые — иначе пустой combo не попадал бы в чекпоинт и перечитывался бы вечно. Оборванный отказом combo on_combo не вызывает — вызов означает «combo пройден до конца». Других потребителей on_combo у яндекса нет (проверено grep'ом; cian'овский fetch_around_multi_room — другой класс).
  • пайплайн run_yandex_city_sweep(resume_run_id=...): done_combos → heartbeat с done_buckets (мерж jsonb — финализаторы mark_done/failed/banned не затирают, проверено по SQL всех трёх). Подхват гейтится единственным якорем: combo_label не содержит якоря, multi-anchor подхват пропускал бы чужие якоря; на проде все свипы одноякорные.
  • планировщик _job_yandex_city_sweep: resume_run_id=_pick_resume(...) — весь generic-механизм #2845 (params-идентичность, свежесть точки, потолок цепочки _MAX_RESUME_CHAIN, машиночитаемый resume_reason) достаётся бесплатно.

Проверки

  • test_3074_checkpoint_survives_claim.py (2): вердикт ok несёт чекпоинт (красный на main по ЗНАЧЕНИЮ), отказный — нет
  • test_3074_yandex_sweep_checkpoint.py (4): пустой пройденный combo доходит до on_combo (красный на main по ЗНАЧЕНИЮ — []), skip_combos не порождает HTTP, планировщик отдаёт resume_run_id (красный по значению — None≠4117), пайплайн-проводка skip→scraper + done_buckets→heartbeat
  • соседи: 151 passed (test_930, test_yandex_city_sweep, test_captcha_vs_empty_detect, test_city_sweep, обе parity, test_2687)

Приёмка на проде: следующий оборванный yandex_city_sweep → у преемника в counters resume_reason='ok', done_buckets растёт между прогонами; сравнить длительность возобновлённого прогона с полным.

Остаток #3074 (не здесь): domclick_city_sweep / avito_city_sweep по образцу.

🤖 Generated with Claude Code

Два шарда #3074 (по коммиту на каждый). ## 1. Чекпоинт переживает обрыв до первой новой корзины Прод-факт: прогон 4707 (23.08) подхватил 42 корзины у 4117, был убит деплоем на 26-й минуте до завершения первой НОВОЙ корзины — и не успел ни разу написать heartbeat с `done_buckets`. Его чекпоинт пуст → следующий кандидат увидел бы `no_checkpoint`, цепочка оборвалась бы. Фикс: `_resume_decision` при `ok` кладёт `done_buckets` предшественника в counters-заготовку нового прогона (персистится при claim; heartbeat мержит jsonb — первый bucket-heartbeat перезапишет надмножеством). Отказные вердикты чекпоинт не наследуют. ## 2. Чекпоинты для yandex_city_sweep Из таблицы убитых деплоем: 15.08 — 65 мин, 12.08 — 2ч33м, оба потеряны целиком (у свипа чекпоинтов не было вовсе). Единица — combo (сегмент × комнатность × диапазон цены), ровно то, чем цикл уже итерируется. - **провайдер** `fetch_around_multi_room`: `skip_combos` (ни одного HTTP по собранным) + `on_combo` для каждого ПРОЙДЕННОГО combo, включая пустые — иначе пустой combo не попадал бы в чекпоинт и перечитывался бы вечно. Оборванный отказом combo on_combo не вызывает — вызов означает «combo пройден до конца». Других потребителей on_combo у яндекса нет (проверено grep'ом; cian'овский fetch_around_multi_room — другой класс). - **пайплайн** `run_yandex_city_sweep(resume_run_id=...)`: `done_combos` → heartbeat с `done_buckets` (мерж jsonb — финализаторы mark_done/failed/banned не затирают, проверено по SQL всех трёх). Подхват гейтится **единственным якорем**: combo_label не содержит якоря, multi-anchor подхват пропускал бы чужие якоря; на проде все свипы одноякорные. - **планировщик** `_job_yandex_city_sweep`: `resume_run_id=_pick_resume(...)` — весь generic-механизм #2845 (params-идентичность, свежесть точки, потолок цепочки `_MAX_RESUME_CHAIN`, машиночитаемый `resume_reason`) достаётся бесплатно. ## Проверки - `test_3074_checkpoint_survives_claim.py` (2): вердикт ok несёт чекпоинт (красный на main по ЗНАЧЕНИЮ), отказный — нет - `test_3074_yandex_sweep_checkpoint.py` (4): пустой пройденный combo доходит до on_combo (красный на main по ЗНАЧЕНИЮ — `[]`), skip_combos не порождает HTTP, планировщик отдаёт resume_run_id (красный по значению — None≠4117), пайплайн-проводка skip→scraper + done_buckets→heartbeat - соседи: 151 passed (test_930, test_yandex_city_sweep, test_captcha_vs_empty_detect, test_city_sweep, обе parity, test_2687) Приёмка на проде: следующий оборванный yandex_city_sweep → у преемника в counters `resume_reason='ok'`, `done_buckets` растёт между прогонами; сравнить длительность возобновлённого прогона с полным. Остаток #3074 (не здесь): domclick_city_sweep / avito_city_sweep по образцу. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-08-26 07:36:11 +00:00
fix(tradein/scheduler): чекпоинт предшественника переживает обрыв до первой новой корзины (#3074)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m35s
e4b3c6cc2b
Прод-факт: прогон 4707 (23.08) подхватил 42 корзины у 4117, был убит деплоем
на 26-й минуте до завершения первой НОВОЙ корзины — и не успел ни разу
написать heartbeat с done_buckets. Его собственный чекпоинт пуст: следующий
кандидат увидел бы no_checkpoint, цепочка оборвалась бы с потерей 42 корзин.

_resume_decision при вердикте 'ok' теперь кладёт done_buckets предшественника
в counters-заготовку нового прогона — она персистится при claim, до старта
пайплайна. Heartbeat мержит jsonb: первый настоящий bucket-heartbeat
перезапишет ключ надмножеством, двойной записи нет. Отказные вердикты
чекпоинт не наследуют (прогон с нуля не должен врать о собранном).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-08-26 07:44:04 +00:00
feat(tradein/yandex): чекпоинты для yandex_city_sweep — combo как единица возобновления (#3074)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
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 4m35s
307055232e
Из таблицы убитых деплоем: yandex_city_sweep 15.08 прожил 65 мин, 12.08 —
2ч33м; оба потеряны целиком — у свипа не было чекпоинтов вовсе.

Единица чекпоинта — combo (сегмент × комнатность × ценовой диапазон), ровно
то, чем цикл обхода уже итерируется. Три слоя:
- провайдер: skip_combos (ни одного HTTP по собранным) + on_combo для каждого
  ПРОЙДЕННОГО combo, включая пустые — иначе пустой combo не попадал бы в
  чекпоинт и перечитывался бы вечно; оборванный отказом combo (gate failure)
  on_combo по-прежнему не вызывает;
- пайплайн: done_combos → heartbeat с done_buckets (мерж jsonb, финализаторы
  не затирают); подхват гейтится единственным якорем — combo_label не содержит
  якоря, multi-anchor подхват пропускал бы чужие якоря;
- планировщик: resume_run_id=_pick_resume(...) в диспатче (generic-механизм
  #2845 — params-идентичность, свежесть точки, потолок цепочки — бесплатно).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend changed title from fix(tradein/scheduler): чекпоинт предшественника переживает обрыв до первой новой корзины (#3074) to feat(tradein): чекпоинты #3074 — наследование при claim + yandex_city_sweep 2026-08-26 07:44:29 +00:00
bot-backend merged commit 993b44c7e0 into main 2026-08-26 07:49:29 +00:00
Owner

Ревью: код корректен, мержу. Два замечания, оба не блокирующие

Проверял по диффу от настоящей базы слияния (729e9acc), а не от текущего main — иначе в дифф попадает чужой test_3082.

Что проверил предметно

  • done_buckets в _resume_decision в области видимости — берётся из prev_counters предшественника (стр. 23 функции), а ветка ok достижима только когда done_n > 0 (ниже есть гард elif done_n == 0 → no_checkpoint), так что if done_buckets else [] — защита от невозможного, не дыра.
  • Заявление «пишется в БД прямо при claim» — правда: _pick_resume вызывает _kit_runs.update_heartbeat(db, run_id, verdict), а done_buckets теперь часть того же вердикта, что и resume_from/resume_chain. Едет по уже существующему пути.
  • Обе ветки колбэка чекпоинтят. Это было моё главное опасение: пустая ветка пишет done_buckets и делает return, и я боялся, что непустая фиксирует только счётчики. Нет — там update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done_combos)}). Иначе в чекпоинт копились бы только пустые combo.
  • Пустой new_lots не гоняет save_listings — контракт on_combo расширен (теперь вызывается и на пустых), и вызывающий это переживает.
  • Multi-anchor честно отклоняется с warning'ом: combo_label якоря не содержит, и пропуск был бы пропуском ЧУЖИХ якорей. Гард по len(_anchors) == 1 правильный.
  • CAST(:rid AS bigint), а не :rid::bigint — по правилу psycopg v3.

1. Тело PR противоречит диффу

В разделе «Что остаётся в #3074 (не здесь)» написано: «yandex_city_sweep — чекпоинтов нет вовсе… отдельный заход». А дифф ровно это и делает: providers/yandex/serp.py (+22) и test_3074_yandex_sweep_checkpoint.py (+241).

Похоже, описание писалось до того, как scope вырос. Важно не ради аккуратности: ревьюер, доверившийся описанию, смержил бы, не заметив, что изменился обход двух-с-половиной-часового прод-скрапера. Стоит поправить тело задним числом или отметить в #3074, что яндекс уже закрыт этим PR.

2. Инвариант «оборванный combo не попадает в чекпоинт» не покрыт тестом

Ключевая строка — if on_combo is not None and not combo_skipped. Именно её нарушение даёт тихую потерю данных: combo, оборванный gate/extraction-отказом, был бы помечен пройденным, следующий прогон его пропустил бы, и объявления оттуда не собрались бы никогда — причём молча, потому что прогон закончится штатно.

Тесты покрывают соседние случаи (on_combo на пустом пройденном combo, нулевые HTTP при skip, передача чекпоинта планировщиком, resume+checkpoint пайплайна) и вердикты ok/refusal, но случая combo_skipped=True среди них нет — инвариант держится только комментарием.

Не блокирую: код виден глазами и очевидно верен, а текущее состояние (убитый прогон теряет ВСЁ) строго хуже. Но это ровно тот инвариант, чей будущий регресс будет неотличим от нормальной работы — просится тест в следующий шард.

Контекст

Прогон 4707 из «прод-факта» — тот самый, что я мерил вчера в #3074: убит деплоем на 26-й минуте, 0 собрано. Приятно видеть его закрытым с той стороны, с которой он и должен был чиниться.

## Ревью: код корректен, мержу. Два замечания, оба не блокирующие Проверял по диффу от настоящей базы слияния (`729e9acc`), а не от текущего main — иначе в дифф попадает чужой `test_3082`. ### Что проверил предметно - **`done_buckets` в `_resume_decision` в области видимости** — берётся из `prev_counters` предшественника (стр. 23 функции), а ветка `ok` достижима только когда `done_n > 0` (ниже есть гард `elif done_n == 0 → no_checkpoint`), так что `if done_buckets else []` — защита от невозможного, не дыра. - **Заявление «пишется в БД прямо при claim» — правда:** `_pick_resume` вызывает `_kit_runs.update_heartbeat(db, run_id, verdict)`, а `done_buckets` теперь часть того же вердикта, что и `resume_from`/`resume_chain`. Едет по уже существующему пути. - **Обе ветки колбэка чекпоинтят.** Это было моё главное опасение: пустая ветка пишет `done_buckets` и делает `return`, и я боялся, что непустая фиксирует только счётчики. Нет — там `update_heartbeat(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done_combos)})`. Иначе в чекпоинт копились бы только пустые combo. - **Пустой `new_lots` не гоняет `save_listings`** — контракт `on_combo` расширен (теперь вызывается и на пустых), и вызывающий это переживает. - **Multi-anchor честно отклоняется** с warning'ом: `combo_label` якоря не содержит, и пропуск был бы пропуском ЧУЖИХ якорей. Гард по `len(_anchors) == 1` правильный. - `CAST(:rid AS bigint)`, а не `:rid::bigint` — по правилу psycopg v3. ### 1. Тело PR противоречит диффу В разделе «Что остаётся в #3074 (не здесь)» написано: «`yandex_city_sweep` — чекпоинтов нет вовсе… отдельный заход». А дифф ровно это и делает: `providers/yandex/serp.py` (+22) и `test_3074_yandex_sweep_checkpoint.py` (+241). Похоже, описание писалось до того, как scope вырос. Важно не ради аккуратности: ревьюер, доверившийся описанию, смержил бы, не заметив, что изменился обход **двух-с-половиной-часового прод-скрапера**. Стоит поправить тело задним числом или отметить в #3074, что яндекс уже закрыт этим PR. ### 2. Инвариант «оборванный combo не попадает в чекпоинт» не покрыт тестом Ключевая строка — `if on_combo is not None and not combo_skipped`. Именно её нарушение даёт **тихую потерю данных**: combo, оборванный gate/extraction-отказом, был бы помечен пройденным, следующий прогон его пропустил бы, и объявления оттуда не собрались бы никогда — причём молча, потому что прогон закончится штатно. Тесты покрывают соседние случаи (`on_combo` на пустом пройденном combo, нулевые HTTP при skip, передача чекпоинта планировщиком, resume+checkpoint пайплайна) и вердикты ok/refusal, но случая `combo_skipped=True` среди них нет — инвариант держится только комментарием. Не блокирую: код виден глазами и очевидно верен, а текущее состояние (убитый прогон теряет ВСЁ) строго хуже. Но это ровно тот инвариант, чей будущий регресс будет неотличим от нормальной работы — просится тест в следующий шард. ### Контекст Прогон 4707 из «прод-факта» — тот самый, что я мерил вчера в [#3074](https://git.gendsgn.ru/lekss361/gendesign/issues/3074#issuecomment-28115): убит деплоем на 26-й минуте, 0 собрано. Приятно видеть его закрытым с той стороны, с которой он и должен был чиниться.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
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#3098
No description provided.