Прошлая правка понижала banned→failed/done по одному диагнозу infra и ломала
обратный контракт: нулевой прогон с infra (yandex 5xx #3196, финализатор #2764)
получал 'failed' — настоящий бан площадки, опознанный как infra, прятался под
«нашу поломку». Хуже исходного дефекта: 2 красных теста в полном прогоне.
Понижение сужено до случая прогона 5425 — dominant='infra' И produced > 0:
брейкер оборвал по доле, а карточки при этом обогащались → 'done'. Нулевой
прогон остаётся 'banned' (честность несёт ban_kind), пустая перепись — тем
более: dominant='unknown', статус не трогаем.
Тесты: контроль на обратную ошибку (ноль результата → banned+infra) и на
пустой census (→ banned+unknown); основной кейс {'infra': 20} при 10
обогащённых — не banned.
Прогон 5425 оборвался по доле блоков и получил статус banned на 48 «блоках»,
из которых 41 был отказом нашего сайдкара: record_block() вида не принимал,
поэтому infra падал в числитель скользящего окна #3184 наравне с настоящим
баном, а mark_backfill_finished считал диагноз только ради телеметрии.
- record_block(kind): в числитель идёт только platform; всё остальное — в
знаменатель (как record_failure), мимо серии и safety-net;
- NoProxyAvailableError у avito — не блок и не отказ площадки: прогон
завершается no_proxy_stop=1 + mark_failed «пул прокси пуст» (как домклик
после #3283); опознаётся по цепочке __cause__, не по подстроке (#3272);
- статус banned — только при доминировании platform; при infra прогон
получает failed (нулевой результат) или done, с честной причиной.
Прод-факт: замер по avito_detail_backfill прочитал «обогащено 0 за 7 дней»
при реальных 801 — джобы пишут результат только ключом 'enriched', которого
_column_counts не знал, и колонка total_seen оставалась 0 у всех прогонов.
'enriched'-фолбэк добавлен именно в _column_counts (витринная колонка), а НЕ в
_RESULT_COUNTER_KEYS: тот список кормит zero-result-стрик, где догнавший
очередь бэкфилл стал бы непрерываемым измеренным нулём — ловушка, за которую
ревью уже выкинуло из списка 'rows_inserted' (#2703). Контракт стрик-сторожа
закреплён инвариант-тестом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью честного run-status нашло, что _RESULT_COUNTER_KEYS ловил не только целевой
yandex_newbuilding_sweep, но и rosreestr_dkp_import (rows_inserted, 66 из 67 прод-
прогонов = здоровый ноль догнавшего инкрементального импорта) и newbuilding_enrich
(processed — счётчик попыток, ==limit даже при частичном провале). Первое завело бы
практически непрерываемый ложный zero-стрик у здорового источника, второе маскировало
бы реальные отказы под measured-N.
Проверено по прод-БД (2026-08-15): "succeeded" пишут ТОЛЬКО yandex_newbuilding_sweep
(42 прогона/90д) и newbuilding_enrich (65/90д) — ни разу rosreestr_dkp_import; у
yandex_newbuilding_sweep succeeded численно совпадает с rows_inserted на всех 42/42
прогонах. Заменил "rows_inserted"+"processed" на "succeeded" в _RESULT_COUNTER_KEYS
(app-копия и byte-эквивалентная kit-копия) — цель (b) исходной правки сохранена, ложный
стрик у rosreestr_dkp_import снят, попутно newbuilding_enrich получает честное
измерение вместо счётчика попыток.
Также поправлены докстринги test_backfill_honest_status.py — два кейса (76%/72%
отказов -> 'done') проверяют только выбор финализатора mark_backfill_finished
(mark_done там замокан); реальный mark_done с honest-run-status переквалифицирует их
в 'failed' через _failed_ratio_too_high — это не документировалось явно.
Три прод-факта, где status='done' врал о реальном исходе прогона:
- avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1}
-> 'done'. mark_backfill_finished звал mark_done, потому что produced=6 (>0);
ни _sweep_run_did_nothing (нет anchors_total/errors_count у backfill'ов), ни
_phase_totally_failed (голые "attempted"/"failed" без фазового префикса) эту
форму counters не ловили. Новый _failed_ratio_too_high внутри mark_done:
failed/attempted >= 0.5 -> 'failed', >= 0.15 -> тоже 'failed' (другая
формулировка причины в error-тексте) — 'partial' статусом не заведён: это
потребовало бы DROP+ADD CHECK constraint (051_scrape_runs_extend.sql) и
дообучения ещё 4 мест (Literal-фильтр admin API, статусы фронта, оба
IN-списка сторожей) — тот же класс проводки, что и у ban_kind (#2686/#2764),
который сознательно не стал новым статусом.
- yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при
processed=5 succeeded=0 rows_inserted=0 failed_resolve=4-5 — сторож нулевого
результата (_alert_if_consecutive_zero_results) не видел ни один результатный
ключ этого sweep'а и молчал навсегда. _RESULT_COUNTER_KEYS дополнен
rows_inserted/processed (именно в этом порядке — rows_inserted это результат,
processed это попытки; иначе "5 обработано, 0 записано" замаскировалось бы
под measured-5).
- admin-витрина показывала new_count=0 у трёх подряд cian_full_load при реально
сохранённых saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count',
ни 'lots_inserted'. _column_counts дополнен saved_inserted/rows_inserted.
Правки продублированы в scraper_kit/orchestration/runs.py (byte-эквивалент
app.services.scrape_runs, см. докстринг модуля) для параллели: единственный
текущий писатель "attempted"/"failed" (mark_backfill_finished) живёт только в
app-копии, но приоритет ключей/константы держим синхронными на будущее.
Не тронуто: сознательно пустые sweep'ы (errors_count=0, honest empty) и малые
батчи (attempted < 3) — доля отказов на них не считается диагнозом.
Tests: tests/test_honest_run_status_failed_ratio.py (41 кейс, оба модуля,
включая точные прод-числа из трёх фактов выше) + regression-прогон 609 тестов
по всем файлам, трогающим scrape_runs/orchestration.runs — 0 регрессий.
Ревью PR #2684 — четыре MINOR.
1. Починка фильтра открыла кнопку отмены на все 53 источника. Раньше таблица была
пуста на каждой вкладке, поэтому кнопка не рендерилась НИ РАЗУ и дыра не
проявлялась: ручки отмены source не проверяют вовсе. Оператор на вкладке Авито
мог бы «отменить» refresh_search_matview — задача продолжила бы работать под
статусом 'cancelled' (ещё один врущий статус ровно в тот день, когда их
вычищаем), а has_running_run перестал бы держать single-run guard, который
существует из-за инцидента с двойным свипом и баном (2026-05-31).
Гейт поставлен на общем узле всех пяти ручек — scrape_runs.honors_cancel +
отказ в mark_cancelled, — а не в UI: иначе ручной POST по-прежнему снимал бы
guard. Флаг cancellable отдаётся в строке, UI по нему прячет кнопку.
Состав набора выведен из call-site'ов runs.is_cancelled: city-sweep'ы (все
площадки и города), full-load'ы, avito_newbuilding_sweep, rosreestr_dkp_import.
Правило НЕ «любой *_sweep»: yandex_newbuilding_sweep отмену не опрашивает.
2. Комментарий пересозданного v_data_quality утверждал, что его обновляет
/api/v1/admin/data-quality. Читателей у view нет ни одного — живая ручка строит
свой запрос. PR с тезисом «ложный показатель хуже отсутствующего» не имеет права
переносить в прод ложное утверждение о читателе.
3. Лимит выдачи 20 → 50: первые 20 строк по started_at на три четверти —
сердцебиение proxy_healthcheck (1631 из 3245), часовой сбор мог не поместиться.
Привязка к вкладке НЕ возвращается.
4. Тест «действующее определение view» искал маркер подстрокой с OR REPLACE —
миграция с обычным CREATE VIEW или парой DROP+CREATE была бы невидима, и тест
проверял бы 214, пока показатель уже вернулся. Заменено регуляркой на обе формы.
Фальсификация трёх новых тестов патч-методом — все три красные. Полный прогон
3490 passed / 9 skipped, tsc --noEmit чистый.
Четыре находки одного класса: админка показывает числа, которые никогда не
бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как
«всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего.
1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с
колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора
вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один
запрос — один и тот же лот выброс для одной оценки и нормальный аналог для
соседней. Persist-флаг на объявлении такое отношение выразить не может,
реализовать пометку нечем.
2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ.
HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной
инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает,
и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у
deactivate_stale_*. Отдельные колонки были бы вторым определением того же.
3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода
его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck.
Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение.
4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT
DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение
точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то
есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже —
он молча подставлял source вкладки. Новый источник появляется в списке сам.
Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408
listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах
с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.
Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус
outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095).