main уехал вперёд за сутки: 234 занял 234_scrape_runs_ban_kind_unknown.sql
(0de22f4b), максимум на main сейчас 239 (235-237 — дыры). max+1=240 безопаснее
дыр; ни один открытый PR номер 235-240 не занимает (сверено по forgejo/main и
всем открытым веткам).
Переименован файл + обновлены все 7 упоминаний "migration 234"
(_manifest_applied.txt, config.py, schemas/trade_in.py,
purge_expired_trade_in_data.py, test_estimate_idor.py, content.ts,
types/trade-in.ts) — правки текстовые, ни один тест не читает миграцию по
имени файла.
Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала
по базовому предикату без retain_until — здоровая оплаченная строка (retain_until
проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик,
и джоба аварийно останавливалась на первой же честной продаже навсегда
(вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции
после этой проверки).
- _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` —
теперь считает только реальную аномалию (retain_until не проставлен, а
платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены
под фактическое поведение.
- Тест на неверный инвариант (`"retain_until" not in sql`) заменён на
позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на
оба случая из ревью (здоровая оплаченная строка не поднимает тревогу,
джоба не блокируется).
- privacy/page.tsx: константа "12 месяцев" вынесена в content.ts
(PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария;
добавлен сверяющий тест (test_paid_retention_text_consistency.py) по
образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое
удаление — задача на проде выключена и ни разу не запускалась, текст
теперь описывает установленный порядок, а не наблюдаемый факт.
- Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены
на краткое изложение сути в комментарии + ссылку на PR #2754.
Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки
WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция
будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен
жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж
безвозвратно снёс бы оплаченное.
Делается ДО платёжного кода, которого в этом PR нет:
- migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено,
бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат.
- config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник
"12 месяцев" для будущей оферты/экрана/SQL продления.
- Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше
SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по
тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при
годовом хранении).
- purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное
не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка +
pre-flight, который считает оплаченных кандидатов и падает в mark_failed
ДО первого батча при ненулевом результате.
- PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL;
"ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут.
- Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at).
- privacy-страница: убрано устаревшее "механизма удаления нет" (неправда
после #2547), добавлен срок 12 месяцев для оплаченных отчётов.
Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours,
_DELETE_EXPIRED_LEADS_SQL не тронуты.
Deep review APPROVE (deep-code-reviewer, 2026-08-06).
HIGH закрыт: purge trade_in_estimates ограничен `created_by IS NULL` — 129 B2C-строк
под удаление, 911 пилотских защищены (сверено на проде: 1040 просрочено всего).
MEDIUM закрыт: телефон в erase_person_data сравнивается по каноническому РФ-виду
с обеих сторон (8→7 при 11 цифрах, без усечения до последних 10).
Проверено: миграции 229/231 прогнаны на прод-схеме в BEGIN…ROLLBACK, тело дважды —
идемпотентны; CHECK consent отбивает false; NN свободны на main и в открытых PR;
consent-гейт недостижим для B2B (session-cookie инжектит X-Authenticated-User);
адрес не попадает в БД раньше согласия ни одним путём.
Гейт: CI Trade-In / backend-tests success 3m9s на 4ee4d4b8.
Follow-up к прошлому фиксу (regexp_replace \D): чистое удаление
форматирования не закрывало разрыв, который сам ревьюер привёл в примере --
"+7 999 123-45-67" и "89991234567" после digit-stripping дают РАЗНЫЕ строки
(79991234567 vs 89991234567, différent на первой цифре) -- классическая для
РФ путаница 8/+7 trunk-префикса.
_ru_phone_norm_sql(expr) добавляет второй шаг: если после digit-stripping
получилось РОВНО 11 цифр с ведущей '8' -- заменить её на '7'. Точное
тождество для российской нумерации, не эвристика (обсуждали: усечение до
"последних 10 цифр" риск-скориальнее -- склеивает номера разных стран,
удаление чужих данных хуже неудаления своих). Оба вызова
(_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL) строят SQL-структуру из
статичных фрагментов (имя колонки / CAST(:phone AS text)) -- ни один
телефон не попадает в текст запроса напрямую.
Живая проверка (throwaway Postgres 16 в docker): лид "89991234567" находится
и удаляется по запросу "+7 999 123-45-67" -- ровно кейс из ревью. Встроенный
counterfactual в самом тесте доказывает, что чистый digit-strip (прошлая
версия фикса) для этой пары находит 0 строк. Negative control: номер,
отличающийся одной значащей цифрой, НЕ удаляется (защита от ложного
совпадения = удаления чужих данных).
Deep-review HIGH: purge_expired_trade_in_data удалял trade_in_estimates по
expires_at без разбора B2B/B2C -- эта колонка TTL ссылки/PDF, а не срок
хранения строки, и её единообразно проставляет каждой оценке estimator.py.
Прод-аудит: 1040/1057 строк просрочены, 911 из них у пилотов (admin,
kopylov, brusnika, praktika, pilottest, admintest, user1). DELETE теперь
ограничен created_by IS NULL -- ровно анонимная B2C-популяция (129 строк).
Докстринг миграции 231 переписан: явные цифры аудита, необратимость,
чек-лист (свежий SELECT count + один supervised прогон) перед enable.
Deep-review MEDIUM: erase_person_data сравнивал phone точным =, а lead.py
сохраняет номер как прислали (без нормализации, намеренно) -- разное
форматирование одного и того же номера не находилось, 0 строк удалялось,
но ответ всё равно был 200 "данные удалены". Сравнение переведено на
regexp_replace(x, '\D', '', 'g') с обеих сторон.
Оба фикса проверены живьём (throwaway Postgres 16 в docker, вне обычного
mock-only CI-лейна): без гварда пилотская строка удалялась вместе с
анонимной; без нормализации разноформатный телефон не находился. С
фиксами -- находит/не находит ровно как задумано. Добавлены self-skipping
live-DB тесты (паттерн test_house_dedup_merge.py::_live_session) плюс
статические SQL-guard тесты.
Миграция 233_payments.sql (payments / payment_notifications / payment_entitlements), поля TBANK_* и PAYMENTS_ENABLED, fail-fast в lifespan. Бизнес-логики нет, контур выключен по умолчанию.
По итогам deep review: UNIQUE NULLS NOT DISTINCT на обоих дедуп-ключах, payment_notifications.processed_at, payments.pd_erased_at, payments_lead_idx, CHECK на длину order_id, статусы сверены с официальной openapi.yaml (Confirm-2, v1.24).
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
192/193 -> 229/230: main занял 192_tradein_users_auth.sql и
193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован
открытым PR #2732 (228_payments.sql) - следующие реально свободные
229/230, порядок consent_proof -> retention сохранён.
Правки ссылок на старые имена/префиксы: docstring-заголовки самих
SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге,
комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py
/ purge_expired_trade_in_data.py, переменные и имена тестов в
test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py.
(Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и
test_team_api.py - это про другие, уже существующие на main миграции
192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту
пару.)
Правки по ревью 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 #2682 нашло контрольную группу в наших же данных. Перепроверено
собственными запросами к проду — сходится, местами хуже заявленного.
1. delisted/relisted УБРАНЫ из писателя событий.
Покрытие обхода за 14-18.07: domklik 99.9-100%, yandex 34-43%, cian 21-27%,
avito 1.6-3.4%. Переходы за те же дни: domklik — снятий 1/2/0/2/4 в сутки и
возвратов РОВНО 0 все пять суток; yandex — снятий 343-433 в сутки. Тот же
обход, тот же день, разница только в покрытии: событие рождается тем, что
скрейпер снова дошёл, а не тем, что объявление вернулось. Подтверждения:
avito 13.07 (день остановки обхода) — 3023 «снятия» за сутки против
контрольной ставки 1-4 (точность ≈4%); 4705 возвратов из 5493 за 12 дней
(85.7%) — это 2-3.08, два дня после возобновления обхода.
Сужение окна свежести сделало бы хуже (больше флапаний). Журнал из догадок
хуже пустого журнала — не пишем. is_active убран из запроса целиком.
Гейт-тест ослаблен до трёх типов + новый гейт «невыводимые НЕ пишутся».
2. TTL-путь пишет 'stale', а не 'closed'.
Прогон по домклику 02.08 деактивировал 6131 объявление за раз (TTL 14 суток
против 12 суток простоя обхода) — под общим статусом это 6131 фальшивая
«дата продажи» одной датой. 'closed' остаётся только за 404: там ответила
площадка. CHECK на колонке нет, миграция 212 обновляет только COMMENT.
3. change_time усечён до суток (date_trunc). С now() UNIQUE(source, change_time,
type) работал только внутри прогона: второй прогон в те же сутки (2 августа
их было два) давал дубли. Теперь заявленная идемпотентность действительно
работает.
4. Комнатность в разборе заголовка стала необязательной: 1991 заголовок из
25 055 (7.9%) — «Квартира-студия, 34,2 м², 9/10 эт.», обязательная группа
роняла match и обнуляла все четыре поля. Чинит обоих писателей сразу
(house_suggestions + house_placement_history, там 8.8% без площади).
Студия → rooms=0 по конвенции kit'а, а не None.
Фальсификация: вернуть delisted — 1 красный; 'closed' на TTL-пути — 6;
обязательная комнатность — 2; now() вместо date_trunc — 1.
Ревью 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