diff --git a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py index 6ae05a73..a238119b 100644 --- a/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py +++ b/tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py @@ -1,11 +1,15 @@ """Чекпоинт avito_city_sweep доживает до финализатора (#3319). -Прод-факт, из которого выросла задача: 0 из 67 прогонов за 60 дней имеют в -counters ключ done_buckets. Механизм #3074 (запись точки) и механизм #930 -(подхват точки) существуют оба, но между ними нет ни одного прогона: точку -писала ровно одна строка внутри цикла якорей, а КАЖДЫЙ выход из прогона -(mark_done — включая ранний выход #1950 «SERP собран, detail заблокирован», — -mark_banned, mark_failed) отдавал голый counters.to_dict() без неё. +Точку писала ровно одна строка — end-of-anchor heartbeat в конце итерации цикла +якорей. Финализаторы её не стирали (все писатели в runs.py мержат jsonb: +`counters || :counters`), дыра в другом: выходы, случившиеся РАНЬШЕ первой такой +записи, точки не оставляли вовсе — cancel/SIGTERM-дрейн на границе первого якоря +и ранний done #1950 («SERP собран, detail заблокирован») на якоре №1. Ими и +кончается типичный прод-прогон с `anchors_done: 1` из 5. + +Замер «0 из 67 прогонов за 60 дней несут done_buckets» тут НЕ доказательство: +строка записи появилась только 26.08.2026 (#3074) при такте avito 7 суток — +выборка почти целиком из эры, где механизма не существовало. Три инварианта, ради которых тест: 1. done-выход несёт done_buckets — иначе точка существует только в логе. @@ -212,3 +216,10 @@ def test_drained_done_is_resumable_but_clean_done_is_not() -> None: resume_from, verdict = _resume_decision(_prev_run(ckpt)) assert resume_from is None, "полный обход подхватывать нечего" assert verdict["resume_reason"] == "status_done" + + # Прогон из эры до #3074: ключей нет вовсе — метка дрейна не должна менять + # вердикт «нечего подхватывать» на что-то другое. + _, verdict = _resume_decision(_prev_run({})) + assert verdict["resume_reason"] == "status_done" + _, verdict = _resume_decision(_prev_run({"interrupted": 1})) + assert verdict["resume_reason"] == "no_checkpoint" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 2cbacf9c..654927ef 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -1186,12 +1186,18 @@ async def run_avito_city_sweep( def _ckpt(**extra: Any) -> dict[str, Any]: """Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319). - До этого точку писала ровно одна строка внутри цикла якорей, а все - финализаторы (mark_done/mark_banned/mark_failed, включая ранний выход - #1950 «SERP OK, detail заблокирован») отдавали голый `counters.to_dict()`. - Точка держалась исключительно на jsonb-мерже в runs.py — на свойстве - ЧУЖОГО модуля, которого этот файл ничем не проверяет; выход, случившийся - раньше первой записи (или писатель без мержа), терял её молча. + До этого точку писала ровно одна строка — end-of-anchor heartbeat в конце + итерации цикла. Финализаторы её НЕ стирали: все четыре писателя в runs.py + мержат jsonb (`counters || :counters`), уже записанный ключ переживал и + mark_done, и mark_banned, и mark_failed. Закрывается другая дыра — выходы, + случившиеся РАНЬШЕ первой такой записи: cancel/SIGTERM-дрейн на границе + первого якоря и ранний done #1950 («SERP собран, detail заблокирован») на + якоре №1. Именно им и кончается типичный прод-прогон, у которого + `anchors_done: 1` из 5. + + Замер «0 из 67 прогонов за 60 дней несут done_buckets» сам по себе этого НЕ + доказывает: строка записи появилась только 26.08.2026 (#3074), а такт avito + — 7 суток, так что выборка почти целиком из эры, где механизма не было. """ return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra}