Правки по ревью PR #2689.
Признак панорамы был недостижим примерно для десятой части страниц. Вызов стоял
после раннего возврата по пустой истории размещений, поэтому идеально отрисованная
страница без единого объявления до записи не доходила: на проде 1519 оценок против
1360 домов с историей. Резолв дома и запись панорамы подняты выше возврата — гейт
честности не тронут. Цена: match_or_create_house теперь вызывается и для таких
страниц (может создать дом), но это тот же вызов с тем же адресом, который уже
отрабатывает на остальных 90%.
Числа в комментариях к схеме были оценками планировщика, а не точным счётом:
listings 142 569 против реальных 93 408 (раздув мёртвыми кортежами на 53%),
house_sources 46 813 против 49 502. На безопасность удаления это не влияло — нули
там точные, — но оценка уезжала в постоянный комментарий к схеме, в PR, тезис
которого «каждое утверждение несёт число с прода». Пересчитано точным count(*).
Окно расписания ДОМ.РФ 03:00-04:00 совпадало с refresh_search_matview — то есть
ровно с тем заданием, которое переносит year_built в поиск. Планировщик берёт
случайный момент внутри окна и гоняет источники параллельно, так что порядок был
подбрасыванием монеты. Перенесено на 01:00-02:00; в комментарии честно сказано, что
гарантии всё равно нет и при аномально долгом прогоне возможно отставание на цикл.
Тесты, адресовавшие вызовы по позиции (db.execute.call_args_list[0]), переведены на
фильтр по SQL — это и была причина, по которой добавление второго execute ломало
шесть чужих тестов разом. То же для side_effect в тесте отката батча: исключение
доставалось бы записи панорамы, которая свои ошибки глотает, и тест молча проверял
бы не тот путь. В test_save_history_items_inserts_each возвращено утверждение о
числе коммитов (было удалено вместо обновления).
Refs #2674
Ревью PR #2688 нашло, что расширение ключа дедупа было неверным. Снимаю его
полностью и добавляю три правки, которых не хватало.
СНЯТО: слияние 781 дома по COALESCE(house_fias_id, gar_house_guid).
Аргумент «общий UUID здания есть независимая идентичность» оказался круговым.
gar_flats_loader проставляет gar_house_guid предикатом
WHERE tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)) = gp.canon
— левая часть побайтово равна ключу канон-прохода, то есть guid является
детерминированной функцией канон-адреса, а не вторым наблюдением. Проход шёл
с выключенным гео-стражем, значит #2187 обходился боковой дверью: канон-проход
отказывается слить два дома в 6 км, а этот сливал их же за «общий UUID»,
выданный за тот же адрес. Плюс gar_pick берёт DISTINCT ON (canon) — одна
ГАР-строка на канон, а 20.3% канонов накрывают несколько зданий, и ЕКБ-фильтр
стоит только на стороне ГАР. Кедровка/Советская 17 уехала бы в ЕКБ. Нужен
ключ, независимый от канона, либо включённый гео-страж — это другая задача.
Приёмник кадастра сужен до кадастра ЗДАНИЯ. Параметр cadastral_number (кадастр
КВАРТИРЫ) убран из match_or_create_house, Protocol HouseMatcher,
RealMatcherAdapter и обоих вызывающих; `cad` больше не падает на него фолбэком.
Мина была отложенной: начни Циан отдавать offer["cadastralNumber"], который
парсер уже читает, — у каждой квартиры свой номер, Tier 0 не сматчил бы
никогда, New-house INSERT записал бы номер квартиры в houses.cadastral_number
и попутно снял P1-страж «безномерный адрес без кадастра не создаём». Две
квартиры одного дома дали бы два дома — то самое дробление. В listings оба
поля пишутся как раньше.
Keeper: listing_cnt DESC NULLS LAST. Счётчик приходит из LEFT JOIN, у дома без
объявлений он NULL, а DESC в Postgres — NULLS FIRST, поэтому пустая запись
обгоняла запись со 192 объявлениями вопреки задокументированному правилу.
Дефект предсуществующий и живой для канон-прохода.
Сторож границы вызова для живого ФИАС-тира. Прежние проверки были
структурными — видели имя параметра в сигнатуре. Уберут аргумент на настоящей
границе (estimator.estimate_quality -> match_house_readonly) — сигнатура цела,
тесты зелёные, тир снова мёртв. Новый тест смотрит на сам вызов. Заявление
«тест ловит неуловимый класс» из прошлого описания снято как преувеличение:
структурная проверка ловит подслучай, и building_cadastral_number её проходит
при нуле срабатываний из 49 502.
Остаётся из первого захода: снятый фильтр поиска has_kadastr, разделение
ФИАС-тира (удалён в пути создания, оставлен в read-only), поправка ложного
утверждения в шапке cadastral_geo_match.py.
Refs #2674
Восемь находок «написано, покрыто тестами, ни разу не сработало» разведены на три
разных диагноза. Две из восьми оказались не мёртвым кодом, а оборванной проводкой.
ПОДКЛЮЧЕНО
Загрузчик ДОМ.РФ. Loader и CLI существуют с #2013, а Handler'а в product_handlers
и строки в scrape_schedules не было — вызвать его было нечем. На проде 29 978 строк
staging с ОДНИМ loaded_at (2026-07-12), то есть ровно один ручной запуск, 24 дня
без обновления. Отсюда кормятся houses.year_built/material_walls/total_floors и
дальше listings.year_built — когортный фильтр эстиматора. Недельный такт, окно
03:00-04:00 UTC (до импорта ДКП и дневных агрегатов).
filters_hash. Парсер читал estimation.sale.data.filtersHash, а Циан кладёт ключ
уровнем выше — estimation.sale.filtersHash. Колонка пуста 0/1658, при том что в
сохранённых сырых ответах хеш есть у 139/139 и все значения различны. Путь исправлен,
139 строк восстановлены бэкфиллом из raw_payload.
has_panorama. Разбирался парсером, лежал в карте приоритетов, обещан публичным
контрактом market.v_houses — и не попадал в houses ни одной строкой кода (0 из 9366).
Пишется там, где yandex_valuation уже держит и house_id, и мету. Гейт честности:
парсер отдаёт bool, а не bool|None, поэтому false пишем только при подтверждённо
отрисованной странице (есть год или этажность) — иначе NULL, а не выдуманный false.
УДАЛЕНО
Дедуп-обёртки эстиматора _phys_dedup_key / _extract_street_token: 25 ссылок, все из
тестов. Хуже, чем просто мёртвые — _phys_dedup_key утверждала правило «ключ = кадастр
ИЛИ улица», которого в боевом дедупе нет (_union_find_phys_dedup держит оба композита
и сливает по любому совпадению). Тесты переведены на живые функции.
Тиерные коэффициенты выкупа asking_to_sold_ratios_tiered + asking_to_sold_tier_bounds:
ноль читателей и писателей, флага tier_aware_ratio_enabled не существует. Посчитаны
один раз при накатке 098 (computed_at 2026-06-27) — тогда как живая
asking_to_sold_ratios обновляется ежедневно (2026-08-05). Методика сохранена в 098.
Колонки без писателя: listings.merged_into (113 уже называла её мёртвой) и
house_sources.raw_payload вместе с GIN-индексом по всегда-NULL колонке.
v_data_quality.price_disagreements_count: у всех 89 699 объявлений ровно один
источник, показатель структурно не мог быть ненулевым, а ноль читался как
«расхождений нет».
ЗАДОКУМЕНТИРОВАНО
BROWSER_BLOCK_RESOURCES выставлен во всех трёх прод-контейнерах, а код перестал его
читать в #1812. Блокировка при этом не ослабла (image глушит camoufox block_images,
font/media — дефолт списка типов), мёртв только выключатель. Сервис теперь говорит
об этом на старте: молча игнорируемая ручка опаснее отсутствующей.
v_price_divergence / v_cross_source_health оставлены как задел, но в COMMENT написано,
почему они пусты структурно: боевой путь загрузки зовёт upsert_listing_source
напрямую и не зовёт match_or_create_listing.
house_sources.ext_url пуст 46 813/46 813, но входит в публичный контракт
market.v_house_sources — оставлен и подписан.
Refs #2674
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод
tradein-postgres, 2026-08-05/06.
Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр
КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает
offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных
building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН
(tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к
cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат
`cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и
писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр.
Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по
построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда
KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260
значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1
значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено
ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту
колонку как подсказку.
Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id
не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в
RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому.
В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть
(payload.target_fias_id / DaData).
Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge
расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один
и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба),
но заполняют его разные источники, и половина в проход не входила. Read-only
прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781
(8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт
(900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар
с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно
аргумент #2187: общий UUID здания старше близости.
Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены
верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%.
Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры
матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть,
передать некому», который обычный тест не видит, потому что зовёт функцию
напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно
потому, что обходили границу вызова.
Refs #2674
Ревью 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).
Ревью PR #2681 опровергло исходную посылку по СберИндексу, и это подтвердилось
на моих же числах (все 24 прогона монитора, read-only):
13-16.07 alert=1 age 73..76 latest=май
17.07 alert=0 age 46 latest=июнь ← день загрузки
18-31.07 alert=0 age 47..60
01-05.08 alert=1 age 61..65
Загрузка ходила раз в 28 дней и приносила период на месяц новее, возраст
считается от первого числа покрытого месяца → пол 46, потолок 74, порог 60
ВНУТРИ диапазона. Тревога срабатывала 14 суток из 28 без всякого застоя
источника: девять срабатываний были замером нашего собственного такта. Поднятие
до ERROR без этой правки завело бы ежедневное ложное событие две недели в месяц.
Миграция 212 переводит sber_index_pull на недельный такт (потолок ≈53 при пороге
60, запас 7 суток) вместо поднятия порога до 75 (запас 1 сутки — ломается от
любого сдвига окна). Цена: 9 запросов в неделю вместо 9 в 28 дней к публичному
sberindex.ru/api/sowa; прогон 4 секунды, 0 ошибок за всю историю.
Дополнительно по ревью:
- поллер Росреестра: ветка «файл найден в листинге, но HEAD не отдал zip» →
ERROR (ровно поведение старой Bitrix-заглушки) + вписана в таблицу уровней;
- тестовый харнесс закрывает клиент событий (фоновый поток на каждый тест).
Refs #2674
Три находки одного класса из эпика: колонка есть, писатель есть, тест на писателя
зелёный — а данные не появляются. Тестами это не ловится по построению, только
сверкой с продом.
1. house_suggestions: парсер выбрасывал imageLink, а INSERT не перечислял
image_link + area_m2/rooms/floor/total_floors. 25 055 строк с NULL во всех
пяти колонках, ~74 дня с миграции 064. Метрики парсятся из title тем же
_parse_title, что и у placementHistory.
2. listings_snapshots.status: 'active' у всех 394 299 строк при 55 448 реально
неактивных объявлений. Оба места вызова с литералом 'active' честны — там
объявление действительно видели; не писал никто ветку «снято». Теперь оба
места деактивации пишут снимок 'closed' в ТОЙ ЖЕ транзакции: TTL-задача
(data-modifying CTE, все 4 источника через один deactivate_stale_listings)
и 404 из avito_detail_backfill. Дата снятия перестаёт быть догадкой.
3. listing_source_events: схема знает 5 типов, писался 1 (price_change, 8288
строк). Дописаны ветки delisted/relisted/edited/first_seen в тот же
set-based statement — данные для них уже лежат в снимке. JOIN → LEFT JOIN
LATERAL, иначе first_seen недостижим по построению; план #2607 (per-row
index point-lookup по idx_lss_source_date) сохранён, проверено EXPLAIN на
проде. Счётчики прогона теперь по типам, все пять всегда присутствуют —
ровно они показали бы четыре нуля из пяти.
Миграция не нужна: все колонки и CHECK уже существуют.
Тесты: tests/test_2674_writers_honor_schema.py. Гейты сверяют писателя со
СХЕМОЙ (колонки INSERT против CREATE TABLE 064, типы событий против CHECK 079),
поэтому ловят и следующую забытую колонку. Фальсификация патч-методом: без
фикса 1 — 6 красных, без фикса 2 — 6, без фикса 3 — 4.
В скрапер-контейнере GlitchTip поднят с LoggingIntegration(event_level=ERROR),
поэтому любой сигнал уровня WARNING событием не становится — сколько бы раз он
ни срабатывал. Прод это подтвердил: монитор устаревания СберИндекса отработал
24 раза, 9 из них со staleness-вердиктом, событий ноль; куки Домклика протухли
2026-08-03 и об этом никто не узнал.
Разбирали не «поменять warning на error», а по каждому сигналу: сбой, из-за
которого данные перестают обновляться — событие; рутина и ожидаемые состояния —
лог. Плюс предупреждение ЗАРАНЕЕ там, где чинить нужно руками (куки Домклика —
по образцу #2658 для Циана, переиспользован тот же подход session_expires_at +
COOKIE_EXPIRY_WARN_DAYS).
У поллера Росреестра выход нового квартала оставлен уровнем info, но получил
явный capture_message(level="info"): новость хорошая, но требует ручного импорта
оператором, а INFO-строка живёт только до ближайшего редеплоя. logger.error для
неё был бы враньём в error-rate и стрик-алертах.
Оговорка: у GlitchTip-проекта сейчас нет ни правил, ни получателей (#2673) —
события станут видны в интерфейсе, но никому не отправятся.
Refs #2674
По ревью PR #2675.
1. counters прогона не заполняли выделенные колонки. _column_counts
(scrape_runs.py) берёт total_seen из ключей total_seen|lots_fetched и
new_count из new_count|lots_inserted — ни одного из них в дикте не было,
поэтому все 39 прогонов этого source лежат в БД с total_seen=0. А mark_done
по этой же колонке шлёт алерт «3 подряд done с нулевым результатом» (#2625):
даже идеальный прогон с 50 сохранёнными считался бы нулевым и через три дня
выстрелил бы ложной тревогой про капчу. Добавлены total_seen=checked и
new_count=saved. Трейд-офф назван в комментарии: на исчерпанной очереди
checked=0 три дня подряд тоже даст алерт — но пустая очередь при ежедневном
расписании это и правда сигнал.
2. _map_renovation_type молча схлопывал в 'cosmetic' любое незнакомое непустое
значение. Сегодня в проде ровно четыре канонических, живого эффекта нет, но
дрейф вокабуляра реален (70950 строк listings с пустым нормализованным
ремонтом). Добавлен logger.debug на случай «непустое, но не в карте» —
паритет с house_type_normalizer, который такой лог уже пишет.
3. Обоснование дефолта 'cosmetic' в докстринге заменено на более сильное по
данным: это одновременно МОДА и МЕДИАННАЯ категория популяции
(standard 7984 / good 7116 / needs_repair 4738 / excellent 2562; кумулятивно
needs_repair 21.2%, +standard 56.8%), то есть наилучшая одиночная догадка, а
не просто «не край шкалы». Там же названа асимметрия: поштучный путь
эстиматора при неизвестном ремонте IMV вообще не зовёт, а домовой дефолтит —
решение осознанное (иначе теряем ещё ~32% домов очереди), чтобы следующий
читатель не принял это за недосмотр.
Refs #2674
Три дефекта в house_imv_backfill, найденные системным поиском (эпик #2674/#2673).
1. Тип ремонта был захардкожен литералом 'cosmetic' — все 2685 запросов ушли
как «косметический ремонт», хотя мода repair_state по объявлениям тех же
домов другая: standard 4564 / good 4118 / needs_repair 2279 / excellent 1631
(косметика лишь 36%). Теперь renovation_type берётся из mode(repair_state)
в том же агрегате, что уже считает медианы комнат/площади/этажа, и проходит
через существующий estimator._IMV_REPAIR_MAP (ленивый импорт — estimator
тянет scraper_adapters, а тот импортирует этот модуль). Второго словаря не
заводим. Неизвестный ремонт (498 домов из 2685) остаётся 'cosmetic': это
середина порядковой шкалы required < cosmetic < euro < designer, а не край,
системного сдвига в одну сторону не даёт.
2. Неизвестный тип дома молча становился 'panel' — и когда типа нет вовсе, и
когда он есть, но не совпал со словарём. Панель почти самый дешёвый класс
(медиана по нашим же 2685 оценкам: block 122.6k < panel 128.8k <
brick 131.1k < monolithic 145.9k руб/м2), то есть дефолт систематически
занижал. На проде так уехали 363 дома совсем без типа и 75 домов с
camelCase-типом из Циана (56 из них monolithBrick — минус 11.7% против
monolithic). Теперь сырое значение прогоняется через общий
scraper_kit.house_type_normalizer.normalize_house_type (знает monolithBrick /
gasSilicateBlock / aerocreteBlock / stalin и SCREAMING-вокабуляр Яндекса),
дефолт 'panel' убран: тип не распознан → house_type=None → дом помечается
no_params ('unknown house_type') и запрос к площадке не тратится. 'other' и
'wireframe' намеренно НЕ маппятся — честного соответствия у них нет.
3. Прогон не умел падать: 31 прогон подряд с saved=0 и ~35 ошибками из 50
помечен 'done'. Тот же класс, что #2670/#2657 — успех определялся как «не
поймали известное исключение». Теперь saved=0 при errors>0 → mark_failed.
Ноль сохранённых БЕЗ ошибок (всё отфильтровано в skipped) остаётся done.
Балкон/лоджия оставлены константами намеренно: покрытие listings.has_balcony
13.8%, listings.balcony_loggia 9.4%, и колонки противоречат друг другу (по
has_balcony «есть» у 62%, а по balcony_loggia самый частый случай — loggia
5650 против balcony 2794). Мода по одному-двум объявлениям на таком покрытии —
шум, а не данные.
Причина, по которой бэкфилл не сохранил НИ ОДНОЙ оценки за 34 дня, — вне этого
модуля и здесь не чинится (детали и числа в описании PR): 1240 домов легли на
отказе браузерного сайдкара «нет прокси» (гейт #2616, 05.07-02.08), а после
возврата прокси 05.08 — 23 на Page.evaluate «Execution context was destroyed»
в tradein-browser и 12 на 403 Авито.
Refs #2674
По ревью PR #2664.
1. test_freshness_window_is_the_estimator_constant_not_a_copy проверял
`lc.LISTINGS_FRESH_DAYS is estimator.LISTINGS_FRESH_DAYS` — CPython кэширует
малые int, поэтому скопированный литерал `LISTINGS_FRESH_DAYS = 14` тест бы
ПРОШЁЛ, хотя докстринг обещает ловить ровно это. Прошлая фальсификация
срабатывала лишь потому, что откат удалял имя целиком (AttributeError).
Теперь проверяем исходник через inspect.getsource — фальсифицировано
подстановкой копии литерала вместо импорта: тест краснеет.
2. Комментарий в location_index.py приписывал свежести чужую заслугу.
Прод-разложение: из −14.8% сдвига городской медианы −14.7 п.п. даёт
сегментный гард и лишь −0.18 п.п. свежесть. Для этой метрики свежесть —
не коррекция смещения, а страховка на будущее, оплаченная третью пула
(3 504 вторичных строки, из них 2 724 живые) и ростом дисперсии: на центре
ЕКБ n 423 → 86, индекс гуляет по выбору окна на 12-14 п.п. Размен верный,
но он должен быть написан как размен.
Там же задокументирован новый режим отказа: свежесть связала витрину со
здоровьем сбора — встанет скрейпинг на 14 дней, и insufficient_data
прилетит всем пользователям разом. Учитывая, что #2574 это месяц молчаливой
поломки сбора, сценарий не гипотетический.
Окно свежести не меняю — вопрос вынесен отдельно.
Refs #2660
Правки по ревью PR #2662.
Фильтр статуса. `GET /admin/scrape/runs?status=skipped` отдавал 422 — 'skipped' не
было в Literal, а во фронте не было чипа. Строки рисовались, но задать вопрос
«что сейчас пропускается» на единственной поверхности, построенной ровно для
этого, было нельзя. Добавлено в оба места (translateStatus «пропущено» и
нейтральный бейдж уже умели).
Схлопывание освежает строку. UPDATE двигал только finished_at/heartbeat_at, из-за
чего живой стрик замерзал: списки прогонов сортируют ORDER BY started_at DESC и
берут limit=20, поэтому 37-дневный пропуск утонул бы под свежими прогонами других
источников — след в базе есть, на экране нет. Теперь started_at = NOW(), а начало
стрика переезжает в counters.first_skip_at; сортировку общего списка не трогаем
(она про все источники, чинить надо было одну строку). Там же обновляется
counters.detail — иначе в строке 37 дней висел текст «протухли 1 день назад»,
хотя именно эта цифра и есть предмет issue. jsonb_set заменён на `||` +
jsonb_build_object: три вложенных jsonb_set читать в 3 ночи невозможно, а NULL в
jsonb_set обнуляет весь counters.
Поиск последней строки. `ORDER BY id DESC` не ложится на индекс
(source, started_at DESC) из миграции 015 — для unknown_source (тикает каждые
60 с бессрочно) это отбор всех строк источника с сортировкой раз в минуту.
Теперь ORDER BY started_at DESC, id DESC.
session_expires_at получил valid_only: предупреждение «скоро протухнут» считает
срок ИМЕННО той записи, которую взял load_session — при нескольких аккаунтах
свежайшая-любая может быть чужой протухшей строкой. Диагностика после None
по-прежнему смотрит на свежайшую любую (валидных там нет по определению).
Запись пропуска намеренно НЕ обёрнута в свой try/except: если db.execute падает,
то падает и claim следующего расписания в этом же тике — тик срывается в любом
случае, а глушить исключение здесь значило бы вернуть ровно тот немой пропуск,
ради которого заведён #2658. Самовосстановление через 60 с.
Пользовательская половина разбора #2574: витрины читают listings без сегмента
и без свежести, поэтому показывают числа, посчитанные не по тому пулу.
1. Миграция 211 — гард #1186 в window_listings у street_sales_vs_listings().
27.3% кандидатов на пару «ДКП ↔ объявление» были новостройками, и
девелоперский прайс (который не торгуется) формировал показываемый процент
торга. is_active здесь по-прежнему НЕ фильтруется — осознанно: функция
намеренно смотрит и снятые объявления, иначе к сделке нечего подставить.
Сигнатура не меняется, значит CREATE OR REPLACE — замена, а не вторая
перегрузка (грабли #2627 закрыты тестом-сравнением сигнатур с м.205).
2. location_index — предикат свежести + сегментный гард в обоих запросах
медианы, симметрично _COMMON_WHERE эстиматора. Витрина обязана смотреть на
тот же пул, на котором считается цена; окно свежести берётся импортом
LISTINGS_FRESH_DAYS, второго определения константы не заводим.
3. /scraper/data-quality и /cache-stats — «активно» не прячем, а разделяем:
рядом отдаётся «из них не виделись N дней» (+ сам порог N в ответе).
Именно слепой count(*) WHERE is_active заставлял #2574 месяц выглядеть
как «всё собирается».
Refs #2660
Пропуск наступившего окна был немым: logger + сдвиг next_run_at, ни строки в
scrape_runs, ни изменения last_run_at. cian_history_backfill так простоял 37 дней
на протухших куках Циана и снаружи выглядел работающим — next_run_at исправно
двигался вперёд, а docker-логи с warning'ом терялись на каждом редеплое.
Статус 'skipped' заведён ещё миграцией 015 и локализован во фронте («пропущено»),
но в проде имел 0 строк — механизм построен и ни разу не использован. Задействуем
его во всех пяти местах, где расписание пропускалось без следа: kit `_claim_run`
(already_running / concurrent_claim / running_appeared_under_lock), kit
`scheduler_loop` (unknown_source) и продуктовый cian `pre_claim`. Причина — слаг в
`error`, по нему «нет кук» отличается от «уже бежит» запросом, а не грепом логов.
Подряд идущие одинаковые пропуски схлопываются в одну строку со счётчиком
`counters.skips`: «уже бежит» и «неизвестный source» не двигают next_run_at и
иначе плодили бы строку каждый тик (60 с).
Алерт про куки жил в недостижимой ветке: он стоял там, где verify_session вернул
None, а на протухших куках load_session сам фильтрует expires_at_estimate > NOW()
и отдаёт None ещё в первой, немой ветке. Теперь алерт в обеих ветках и через
logger.error — в scraper-контейнере GlitchTip поднят с LoggingIntegration
(event_level=ERROR), поэтому прежний capture_message(level="warning") событием не
становился. Плюс предупреждение ЗАРАНЕЕ (COOKIE_EXPIRY_WARN_DAYS=5) в том же
pre_claim: обновление кук — ручная операция, алерт по факту протухания приходит,
когда сбор уже встал.
Монитор нулевых прогонов (#2625) не трогаем: обе alert-выборки отбирают
failed/banned/done/cancelled, поэтому 'skipped' в стрик не попадает и его не
прерывает — пропуск не «прогон вернул ноль лотов», смешивать нельзя.
По итогам глубокого ревью PR #2661.
1. Ослабление replay-стаба выключало канарейку «реплей разошёлся с захватом»
навсегда и для всех будущих PR, а не только для 19 сделок этой правки.
Теперь число неотвеченных lookup-вызовов считается и выносится в метрику
unrecorded_lookup_calls — baseline сравнивает целые ТОЧНО, поэтому 19 стало
закоммиченной константой: новый незаписанный путь снова валит гейт громко,
а перезахват фикстуры доведёт число до нуля.
Перезахват фикстуры (лучший путь по ревью) не сделан осознанно: он требует
живой прод-БД tradein и тянет НОВУЮ выборку сделок, то есть не «дозаписывает»
19 ответов, а меняет саму систему отсчёта регресс-гейта по причинам, не
связанным с этим PR.
Попутно найдено и починено: документированная регенерация baseline
(--from-fixture --update-baseline) с #2173 писала baseline, который гейт не
мог совпасть НИКОГДА — тест пиннит estimate_dedup_analogs_enabled=False, а
CLI нет. Пин переехал внутрь replay_fixture, теперь оба пути согласованы.
Регенерированный baseline отличается ровно одной строкой (новый ключ), ни
одна метрика не сдвинулась.
2. Сброс anchor_tier открывал щель в отображении: комплы якоря добыты, якорь не
построен, headline подавлен → не срабатывал ни blend, ни display-only блок, и
пользователь терял карточку Avito IMV. Гейт по tier снят, защита от двойного
заполнения (`avito_imv_summary is None`) остаётся.
3. Тест мест теперь стережёт и inline-копию _COMMON_WHERE в Tier W — самое
вероятное место следующего расхождения того же сорта.
Фальсификация: правка baseline 19→18 валит гейт; снятие предиката из Tier W
валит тест мест; возврат старого условия display-блока валит новый тест карточки.
Цена местами строилась на объявлениях, которых никто не видел месяц. Главный
радиусный путь эстиматора нёс `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней`
(_COMMON_WHERE), а четыре денежные выборки — нет:
- `_fetch_anchor_comps` Tier A и Tier C: якорь ЗАМЕЩАЕТ headline
(median_ppm2/median_price/n_analogs), т.е. правит цену напрямую;
- `asking_to_sold_ratio` ask_side и ask_global: знаменатель коэффициента,
на который умножается expected_sold_price.
`is_active` свежесть не заменяет — он означает разное у разных источников
(TTL деактивации 30 дней, NULL-сегмент не деактивируется никогда), а
`scraped_at` одно и то же. Прод: 21 132 из 37 497 активных строк протухли по
14-дневной мерке самого эстиматора и были полностью годны для якоря.
Окно вынесено в app.core.config.LISTINGS_FRESH_DAYS (одно место на всех):
держать его в estimator.py нельзя — тот сам импортирует area_bucket из
app.tasks.asking_to_sold_ratio, обратный импорт дал бы цикл.
Второй половиной — залипший anchor_tier: он оставался "C"/"A", когда якорь не
был построен, и молча глушил IMV-blend, quarter-index (#764 Guard-1a),
radius-floor и corridor-clamp-exempt Tier A. Теперь сбрасывается явно, у
источника, для всех трёх причин (None из _compute_same_building_anchor, гейт
Tier C #1795, low-conf гейт #audit-1).
Обе половины одним PR намеренно: порознь они дадут два заметных скачка цены
вместо одного меньшего (якорная половина −3.63%, знаменатель +1.25%,
вместе −2.42% от суммы выкупа на 1040 реальных оценках).
Тесты: tests/test_freshness_filter_2656.py — предикат во всех четырёх местах,
единственность константы, бинд :fresh_days, протухший комп не в пуле якоря,
сброс anchor_tier и разглушённый IMV-blend. Все 7 краснеют без фикса
(проверено git stash).
Первый коммит починил только scripts/geocode_deals_nominatim.py — ручной скрипт.
Тот же дефект оставался на живом пути: POST /admin/geocode-missing?target=deals
отдавал сырой row["city"] в city_hint, а deals.city росреестровое и в хвосте
распределения содержит не-города («Бессонова», «Билейский рыбопитомник»). Любой
не-ЕКБ хинт жёстко закрывает EKB-локальные тиры и уезжает префиксом в запрос
провайдеру, то есть мусорный хинт хуже отсутствия хинта.
Гейт вынесен в geocoder.known_city_hint (сверка с SVERDLOVSK_OBLAST_CITIES —
тем же набором, который уже питает _names_non_ekb_city / _ekb_local_tiers_allowed)
и переиспользуется всеми тремя потребителями city_hint: скриптом, admin-ручкой и
задачей geocode_missing. Копий функции нет — четвёртый потребитель, если появится,
получит гейт сам.
Тесты: мусорный город -> хинт не передаётся, валидный -> передаётся; проверено
фальсификацией (без фикса все три новых теста краснеют).
Правки по deep-review PR #2654.
MEDIUM. Внутренний EXISTS считал backup'ом любой enabled-узел affinity. До п.2 это
было эквивалентно «пригоден», потому что бан выключал узел глобально; теперь узел
бывает enabled и одновременно забанен СВОИМ же источником. Fallback мог увести
последний реально рабочий узел выделенной affinity (два domclick-узла, один забанен
domclick'ом → второй уходит под avito → domclick без прокси). Добавлено требование,
что backup не забанен своим источником — в acquire и зеркально в защите mark_banned.
MEDIUM. У оператора не осталось способа снять бан: в п.1 ложное срабатывание
лечилось PATCH enabled=true (он обнулял disabled_reason), теперь бан живёт в
отдельной таблице и истекает только по таймеру, до 72ч при эскалации. Добавлен
proxy_pool.clear_source_bans; зовётся из patch_proxy при ручном включении и после
УСПЕШНОЙ ротации exit-IP (бан привязан к proxy_id, а банился IP — после смены
адреса строка держала бы узел вне выдачи без причины).
LOW. Тест защиты дублировал логику вместо её проверки: ban-предикаты в фейксессии
теперь гейтятся по подстрокам боевого SQL (как в acquire-ветке) — проверено
мутацией, тесты краснеют при удалении NOT EXISTS из запроса.
LOW. Конверсия в миграции 210 матчила disabled_reason по LIKE 'banned:%' и могла
отменить ручное выключение оператора (формат подсказан комментарием 209-й) — сужено
до точного списка значений домена provider_affinity.
LOW. Docstring report_ban в browser_fetcher описывал старую модель (enabled=false);
формула в COMMENT ON COLUMN была на шаг мимо (срок ТЕКУЩЕГО бана, не следующего).
Расхождение с acquire по leased_by зафиксировано в докстринге как осознанное.
Refs #2600
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком
(enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито
банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник
выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют
(#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан
не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен
был ручной PATCH.
Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans:
acquire(source) не выдаёт узел только этому источнику, для остальных узел
первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72
(потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем
истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по
banned_until < now(). Защита последнего узла сохранена, но считается по
источнику: если после бана у acquire(source) не останется кандидатов — бан не
пишется, WARNING зовёт пополнять пул.
Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason
LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе
они висели бы выключенными вечно.
Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без
этого «узел включён, но не выдаётся» необъяснимо.
Refs #2600
house_metadata (OSM/кадастр) отдаёт year_built без валидации; на проде
встретился 1829, который клампился в нижний пол хедонического фактора
(estimate_hedonic_factor_min=0.75) и резал выкупную цену на фикс. -25%
без физического смысла — модель никогда не видела осмысленного объёма
домов старше 1955 (см. COHORTS). Год вне [1917, текущий+3] теперь
трактуется как отсутствующий (None, нейтральный year-term) вместо
клампа, с WARNING-логом по house_id/адресу.
Единая точка входа _sanitize_build_year() в estimate_quality() перед
cohort-фильтром, house-match scoring и хедоническим фактором — покрывает
оба источника года (payload.year_built и house_meta.year_built).
Root cause: event-diff CTE джойнил "today" (снимок за CURRENT_DATE) с "prior"
(DISTINCT ON по всей listing_source_snapshots, ~2.6-2.8M строк) обычным JOIN.
Планировщик оценивал today в 1 строку (свежевставленные в той же транзакции
строки ANALYZE ещё не видел) → Nested Loop без Materialize пересчитывал
DISTINCT ON по всей таблице заново на каждую из ~80-140k реальных строк today
(EXPLAIN на проде: cost≈300k на этом шаге) — прогон не укладывался ни в 6h
zombie-порог, ни в сутки, каждую ночь минимум с 19 июля.
Переписано на JOIN LATERAL (per-row indexed point-lookup через idx_lss_source_date,
cost упал до ~4.4/строку). Плюс budget_sec → SET LOCAL statement_timeout как
defense-in-depth (по образцу geocode_missing_listings) — задача теперь честно
падает в mark_failed вместо того чтобы висеть сутками, если план когда-нибудь
разрегрессирует снова.
Зомби-детектор (reap_zombies) не тронут — он только помечает scrape_runs.status,
не убивает backend (нет pid/application_name в схеме run'а); pg_terminate_backend
для этого — отдельный follow-up, не в этом PR.