diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index ccac68af..6fca052d 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -143,18 +143,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше # сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов # реально дали ноль. -# rows_inserted — yandex_newbuilding_sweep (единственный писатель ключа с таким -# именем на верхнем уровне counters): проверено на проде 26.07-10.08 — -# десять прогонов подряд, все 'done', processed=5 succeeded=0 -# rows_inserted=0 failed_resolve=4-5. Ни total_seen/lots_fetched/ -# unique_fetched у него нет, поэтому раньше _run_result_count всегда -# возвращал None ("не измерено") и стрик у сторожа не копился никогда -# (honest-run-status). -# processed — тот же sweep: сколько домов взял в работу. НАМЕРЕННО стоит ПОСЛЕ -# rows_inserted в кортеже — processed это счётчик ПОПЫТОК (аналог -# attempted), а не результата: у него ненулевое значение (=limit) даже -# когда rows_inserted=0, и если бы он читался первым, «5 обработано, -# 0 записано» замаскировалось бы под measured-5, а не measured-0. +# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich +# (65 прогонов/90д, единственные два писателя ключа на проде, +# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и +# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в +# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта +# (rows_fetched=rows_skipped=96974, last_id не двигается неделями), +# а не отказ; если бы 'rows_inserted' попал в этот список, сторож +# зачитывал бы этот здоровый ноль как измеренный провал и копил бы +# практически непрерываемый стрик (rosreestr_dkp_import не +# прерывается другим статусом — импорт либо 'done', либо не бежал). +# НЕ 'processed' по той же причине с другой стороны: это счётчик +# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже +# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14, +# 44% отказов замаскировались бы под measured-25) — сторож нулевого +# результата на нём молчал бы ровно там, где должен сработать, а на +# будущем опустении очереди домов (cian_houses_pending) создал бы +# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep +# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах — +# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08, +# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше +# ни total_seen/lots_fetched/unique_fetched не было, и +# _run_result_count всегда возвращал None (honest-run-status)). # Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 # прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь # (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched @@ -166,8 +176,7 @@ _RESULT_COUNTER_KEYS = ( "total_seen", "lots_fetched", "unique_fetched", - "rows_inserted", - "processed", + "succeeded", ) @@ -368,14 +377,16 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: Приоритет ключей: - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / - rows_inserted / processed) + succeeded) - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала new_count=0 у трёх подряд cian_full_load при реально сохранённых saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, - которым yandex_newbuilding_sweep сообщает число upsert'ов. + которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов; + здесь (для витринной колонки new_count) это безопасно — в отличие от + _RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика. Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE). diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py index ad72db34..a885b74e 100644 --- a/tradein-mvp/backend/tests/test_backfill_honest_status.py +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -5,7 +5,17 @@ 1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30 (494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done'). -Проверяем ровно ветвление mark_backfill_finished — БД замокана. +Проверяем ровно ветвление mark_backfill_finished — БД замокана (mark_done/mark_failed/ +mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы +ниже с высокой долей отказов (attempted=50, failed=38 или 36 — 76%/72%), ожидающие +'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674: +"обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С +honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed' +через _failed_ratio_too_high (доля отказов >= 0.5) — реальный терминальный статус +для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется +отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py +(test_prod_fact_avito_15_08_no_longer_done и соседние) — не читай эти два кейса как +"76%/72% отказов = 'done' в проде". """ from __future__ import annotations @@ -59,9 +69,15 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st ({"attempted": 5, "enriched": 0, "failed": 5}, False, "failed"), # Кандидатов не было — честная пустота, это успех. ({"attempted": 0, "enriched": 0, "blocked": 0, "failed": 0}, False, "done"), - # Частичный прогон: обогатили хоть что-то → успех. + # Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ + # mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done + # замокан, поэтому статус остаётся 'done'; в реальном mark_done с + # honest-run-status (2026-08-15) это переквалифицируется в 'failed' + # (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля. ({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"), - # Блоки были, но прогон доработал и обогатил — не бан. + # Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает + # НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done + # переквалифицирует в 'failed', см. докстринг модуля выше. ({"attempted": 50, "enriched": 12, "blocked": 2, "failed": 36}, False, "done"), # Блок оборвал прогон, хотя часть успели обогатить — работа не доделана. ({"attempted": 50, "enriched": 12, "blocked": 5, "failed": 33}, True, "banned"), diff --git a/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py index b8c03639..c32e4609 100644 --- a/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py +++ b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py @@ -12,8 +12,15 @@ processed=5, succeeded=0, rows_inserted=0, failed_resolve=4-5 — сторож нулевого результата (_alert_if_consecutive_zero_results) слеп, т.к. _RESULT_COUNTER_KEYS не знал ни одного ключа этого sweep'а (total_seen/lots_fetched/unique_fetched). - Фикс: _RESULT_COUNTER_KEYS дополнен rows_inserted/processed (в этом порядке — - rows_inserted это РЕЗУЛЬТАТ, processed это ПОПЫТКИ). + Фикс: _RESULT_COUNTER_KEYS дополнен 'succeeded'. Первая версия правки добавляла + голые 'rows_inserted'/'processed' — ревью нашло, что 'rows_inserted' пишет ЕЩЁ + rosreestr_dkp_import (66/67 прод-прогонов, здоровый ноль догнавшего импорта, а не + отказ) и завёл бы непрерываемый ложный zero-стрик, а 'processed' — счётчик + попыток (==limit даже при частичном провале у newbuilding_enrich) и маскирует + реальные отказы. 'succeeded' пишут только yandex_newbuilding_sweep и + newbuilding_enrich, численно совпадает с прежним 'rows_inserted' на всех + прод-прогонах sweep'а — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured + и test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже. (c) admin-витрина показывала new_count=0 у трёх подряд cian_full_load, хотя реально сохранено saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count', ни @@ -180,7 +187,8 @@ def test_honest_empty_sweep_unaffected_by_failed_ratio(name: str) -> None: def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None: """processed=5, succeeded=0, rows_inserted=0, failed_resolve=4 — раньше - _run_result_count возвращал None ("не измерено"); теперь — измеренный 0.""" + _run_result_count возвращал None ("не измерено"); теперь — измеренный 0 (через + 'succeeded', не 'rows_inserted' — см. ниже, почему ключ переигран ревью).""" counters = { "total": 309, "fetchable": 200, @@ -198,23 +206,61 @@ def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None: assert kit_runs._run_result_count(counters) == 0 -def test_rows_inserted_takes_priority_over_processed() -> None: - """rows_inserted (результат) читается ПЕРЕД processed (попытки) — иначе "5 - обработано, 0 записано" замаскировалось бы под measured-5.""" +def test_succeeded_is_the_measured_key_not_rows_inserted_or_processed() -> None: + """'succeeded' читается как результат; голые 'rows_inserted'/'processed' в + _RESULT_COUNTER_KEYS больше не участвуют (были в первой версии правки, снято + ревью — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured и + test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже).""" counters = {"processed": 5, "rows_inserted": 0} - assert app_runs._run_result_count(counters) == 0 + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None -def test_processed_is_fallback_when_rows_inserted_absent() -> None: - counters = {"processed": 3} - assert app_runs._run_result_count(counters) == 3 +def test_rosreestr_dkp_import_healthy_zero_stays_unmeasured() -> None: + """Прод-факт rosreestr_dkp_import (2026-08-15, 66 из 67 прогонов за 90д): инкрементальный + импорт догнал источник — rows_fetched==rows_skipped, rows_inserted=0. Это ЗДОРОВЫЙ + ответ (нечего вставлять), а не отказ; словарь не содержит 'succeeded' вовсе. + + Первая версия правки добавляла голый 'rows_inserted' в _RESULT_COUNTER_KEYS — тогда + этот прод-факт читался бы как "измеренный провал" и копил бы практически + непрерываемый zero-стрик (rosreestr_dkp_import не прерывается другим статусом: + он либо 'done' с этим же нулём, либо не бежал). Ревью поймало это до деплоя — + правильный ответ: "не измерено" (None), стрик не копится.""" + counters = { + "last_id": 6829903, + "batches_done": 49, + "rows_errored": 0, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_updated": 0, + "rows_inserted": 0, + } + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None + + +def test_newbuilding_enrich_partial_failure_not_masked_by_processed() -> None: + """Прод-факт newbuilding_enrich (09.08): processed=25 (счётчик ПОПЫТОК, ==limit), + succeeded=14 — 44% отказов. Если бы сторож читал 'processed' как результат, партиальный + провал замаскировался бы под measured-25 (сторож нулевого результата промолчал бы + ровно там, где должен был сработать при полном провале). 'succeeded' даёт честные 14.""" + counters = { + "failed": 11, + "enriched": 14, + "attempted": 25, + "processed": 25, + "succeeded": 14, + "failed_fetch": 11, + } + assert app_runs._run_result_count(counters) == 14 + assert kit_runs._run_result_count(counters) == 14 @pytest.mark.parametrize("name", list(_MODULES)) def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) -> None: - """(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с - rows_inserted=0 -> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал - результат "не измеренным" и молчал бы вечно (см. #2703 в docstring модуля).""" + """(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с succeeded=0 + -> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал результат "не + измеренным" и молчал бы вечно (см. #2703 в docstring модуля).""" mod = _MODULES[name] row = MagicMock() row.status = "done" @@ -228,6 +274,29 @@ def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) mock_sentry.capture_message.assert_called_once() +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_result_watchdog_silent_on_rosreestr_dkp_import_streak(name: str) -> None: + """Негативный аналог теста выше: та же лестница из 3 подряд 'done', но словарь + rosreestr_dkp_import (нет 'succeeded') -> сторож не считает результат измеренным + и НЕ шлёт алерт — регрессионный тест на замечание ревью (HIGH #1).""" + mod = _MODULES[name] + row = MagicMock() + row.status = "done" + row.counters = { + "last_id": 6829903, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_inserted": 0, + } + db = MagicMock() + result = MagicMock() + result.fetchall.return_value = [row, row, row] + db.execute.return_value = result + with patch.object(mod, "sentry_sdk") as mock_sentry: + mod._alert_if_consecutive_zero_results(db, "rosreestr_dkp_import") + mock_sentry.capture_message.assert_not_called() + + # ── (c) _column_counts: прод-факт cian_full_load new_count=0 при saved_inserted>0 ─── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py index 279ea928..725b1613 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/runs.py @@ -138,18 +138,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # unique_fetched — full-load'ы avito/cian/yandex (4 источника, 133 прогона) — раньше # сторож их не видел, хотя у cian_full_load 6 из 38 успешных прогонов # реально дали ноль. -# rows_inserted — yandex_newbuilding_sweep (единственный писатель ключа с таким -# именем на верхнем уровне counters): проверено на проде 26.07-10.08 — -# десять прогонов подряд, все 'done', processed=5 succeeded=0 -# rows_inserted=0 failed_resolve=4-5. Ни total_seen/lots_fetched/ -# unique_fetched у него нет, поэтому раньше _run_result_count всегда -# возвращал None ("не измерено") и стрик у сторожа не копился никогда -# (honest-run-status). -# processed — тот же sweep: сколько домов взял в работу. НАМЕРЕННО стоит ПОСЛЕ -# rows_inserted в кортеже — processed это счётчик ПОПЫТОК (аналог -# attempted), а не результата: у него ненулевое значение (=limit) даже -# когда rows_inserted=0, и если бы он читался первым, «5 обработано, -# 0 записано» замаскировалось бы под measured-5, а не measured-0. +# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich +# (65 прогонов/90д, единственные два писателя ключа на проде, +# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и +# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в +# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта +# (rows_fetched=rows_skipped=96974, last_id не двигается неделями), +# а не отказ; если бы 'rows_inserted' попал в этот список, сторож +# зачитывал бы этот здоровый ноль как измеренный провал и копил бы +# практически непрерываемый стрик (rosreestr_dkp_import не +# прерывается другим статусом — импорт либо 'done', либо не бежал). +# НЕ 'processed' по той же причине с другой стороны: это счётчик +# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже +# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14, +# 44% отказов замаскировались бы под measured-25) — сторож нулевого +# результата на нём молчал бы ровно там, где должен сработать, а на +# будущем опустении очереди домов (cian_houses_pending) создал бы +# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep +# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах — +# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08, +# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше +# ни total_seen/lots_fetched/unique_fetched не было, и +# _run_result_count всегда возвращал None (honest-run-status)). # Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 28 источников (2650 # прогонов) не имеют общего результатного ключа вовсе — у каждого свой словарь # (deactivated / rows_written / poi_loaded / snapshotted / upserted / listings_matched @@ -161,8 +171,7 @@ _RESULT_COUNTER_KEYS = ( "total_seen", "lots_fetched", "unique_fetched", - "rows_inserted", - "processed", + "succeeded", ) @@ -368,14 +377,16 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: Приоритет ключей: - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_fetched / - rows_inserted / processed) + succeeded) - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала new_count=0 у трёх подряд cian_full_load при реально сохранённых saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, - которым yandex_newbuilding_sweep сообщает число upsert'ов. + которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов; + здесь (для витринной колонки new_count) это безопасно — в отличие от + _RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика. Возвращает (total_seen, new_count); None для ключа, которого нет в counters — тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).