Ревью #3373, два minor:
1. `_leaf` считал полноту только по потолку страниц — упавшая страница
(`payload=None` → `[]`) молча уходила в чекпоинт как собранная. Теперь
паритет с cian по-настоящему: `complete = not capped and dropped_pages == 0`.
2. `counters.capped_buckets` присваивался ПОСЛЕ await — cancel/shutdown
(RuntimeError из `_on_bucket`) уносил управление мимо строки. Перенесено в
`finally`, счётчик виден в финальном payload дрейна.
Нит: `ceil(total / 20)` → `_GATE_PAGE_SIZE`.
Тесты по значению: бакет с 1 выпавшей страницей из 3 — не в done-леджере
(+ контроль: 3 успешных страницы по-прежнему complete); дрейн после обрезанного
бакета → `capped_buckets == 1` в финальных counters. Фейк `_DrainAtFirstBucket`
(#3355) теперь объявляет `capped_buckets` явно: его catch-all `__getattr__`
отдавал корутину на любое имя и ронял сериализацию counters.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ревью PR #3363:
1. domclick city sweep: комментарий обещал, что унаследованный при claim
чекпоинт переживёт дрейн «jsonb-мержем» — мержа нет. domclick пишет
counters через app-level scrape_runs (update_heartbeat/mark_done делают
`counters = CAST(:counters AS jsonb)`, полная перезапись), а scheduler
при claim done_buckets не наследует. Дрейн закрывал прогон с ПУСТЫМ
чекпоинтом, резюм пересобирал все шесть корзин. Чтение чекпоинта поднято
выше ранних выходов, дрейн-payload несёт `done_buckets` явно — как уже
делает ban-ветка (except NoProxyAvailableError) той же функции.
2. cian full-load, detail-фаза: второй ранний выход по shutdown_requested()
делал `break` и уходил в обычный mark_done — SERP целый, обогащение
обрезано, а прогон выглядел полным. Теперь тот же sentinel
RuntimeError("shutdown"), что и в _on_bucket: interrupted=1 + done_buckets.
3. yandex/avito full-load проверены: по одному shutdown-сайту в _on_bucket,
detail-фазы нет вовсе — аналогичного дефекта нет.
Тесты: чекпоинт дрейна domclick сверяется ПО ЗНАЧЕНИЮ в обеих записях
(heartbeat + финализатор); дрейн cian в detail-фазе даёт interrupted=1.
После #3330/#3346 резюм берёт 'done' только с counters.interrupted=1, но четыре
функции с живым чекпоинтом done_buckets финализировали дрейн чистым 'done':
cian/yandex/avito full-load (ветка RuntimeError("shutdown")) и domclick city
sweep (дрейн до SERP). Оборванный обход был неотличим от полного, а собранные
бакеты никто не подхватывал — у avito это ещё и бан-бюджет (#3315).
done_buckets в domclick-payload не добавляем: чекпоинт унаследован при claim
(#3074), jsonb-мерж его сохраняет.
Closes#3355