#2690 просил ключ, независимый от нормализованного адреса. Проверил на живой таблице
все поля houses — независимого наблюдения нет ни одного, и это вывод, а не пауза.
cadastral_number 2648 заполнено, ВСЕ 2648 значений различны → схлопывает ноль. Все 2648
несут dadata_enriched_at и house_fias_id, то есть это ответ DaData на
нашу же строку адреса. Второй кадастр (KNN-подсказка листингов) отвергнут
ещё в #2674: 20.1% значений накрывают >1 здания ГАР.
house_fias_id 3678 заполнено, все различны → ФИАС-проход сегодня сливает 0 строк.
gar_house_guid перемерено: из 458 пар с общим guid 441 делят канон (guid его повторяет),
17 нет — и 5 из них дальше 250 м, худшая 5064 км. Круговой как был.
zhkh_house_guid выглядит внешним реестром и им не является: loader ставит его
WHERE gar_house_guid = <guid>, т.е. это И ЕСТЬ ГАР-guid у 4268 из 4663.
Из 194 пар с РАЗНЫМ каноном 193 приходят кадастровым фолбэком (та же
KNN-подсказка), и 30 из 31 пары дальше 250 м — тоже он.
source+ext_house_id / cian_internal_house_id / yandex_jk_id — различны по построению / 39 / 0.
координаты настоящее независимое наблюдение, но не идентичность: у соседей общий двор.
Уже используются единственным осмысленным способом — как страж.
год+этажность ложный свидетель: из 391 пары, которую страж判 не может рассудить, оба поля
совпадают у 18 (у 357 есть NULL), зато у 306 пар, отвергнутых стражем
дальше 250 м, они совпадают — признак подтвердил бы заведомо неверное.
Поэтому ключ не усиливаю. Вместо этого фиксирую остаток числом, которое живёт: перепись
после обоих проходов пишет в counters прогона, сколько однокононных строк осталось и ПОЧЕМУ.
Разовый замер протухает быстро — «781 лишняя строка» из шапки задачи через четыре дня стала
963, после того как прогон удалил 821.
Корзины намеренно НЕ складываются в один «остаток»: «страж молчит» (нет координат у стороны)
и «страж отверг» (дальше 250 м) — противоположные факты. Прод 2026-08-10, 963 строки:
568 из них дальше 250 м, медиана 1084 м — это вообще не дубли, канон-ключ о них врёт.
Отдельная корзина residual_mergeable — растяжка на сам проход: прошло все стражи и не слилось,
ожидание 0.
Перепись делит с канон-проходом один и тот же префикс кластеризации (_ranked_cte) — своя копия
разъехалась бы с проходом, который описывает, и разъезд был бы невидим. Падение переписи не
роняет слияние: слияние — продукт, счётчик — прибор.
Прод-сверка ДО мержа: отрендеренный _RESIDUAL_SQL на боевой БД даёт ровно то, что независимо
намерено вручную — 963 / 1765 объявлений / 326 молчит / 568 отверг / 8 cross-fias / 61 сольётся.
Попутно две правки честности шапки:
* снято утверждение «cadastral_number is 100% NULL on prod» — неверно с 2648 строк, и вывод
про адресный ключ на нём больше не держится;
* записан результат проверки правила выбора победителя (#2690 п.3). Правило было починено
в #2674 (listing_cnt DESC NULLS LAST) и проверено задним числом по house_merge_log: из 821
слияния 08.08 ноль выбрали победителя беднее проигравшего. Контрфактика старого правила на
тех же кластерах — 6 из 762 забрали бы пустого победителя. Там же предупреждение: мерить
победителя до слияния по listings.scraped_at нельзя, #2206 двигает его при ре-подтверждении
и порождает 207 несуществующих «худших победителей».
Гео-ограждение 250 м не тронуто. Расшивка уже слитого не предлагается.
Тесты красные на старом коде: 4 из 4 (KeyError residual_rows / нет _RESIDUAL_SQL).
Refs #2690
Авито с 27.07 рендерит рейтинг и число отзывов внутри того же <p> в
data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4
отзыва». Прод 2026-08-10: 1 123 активных объявления с таким адресом, и у 1 123 из
1 123 нет координат — доля 100%, 711 из них геокодер уже пробовал. Контроль в тех
же данных: у чистых адресов координаты есть у 4 766 из 5 715 (83%).
Строка без geom молча выпадает из comp-пула: Tier W отбирает через ST_DWithin, а
NULL не проходит предикат и нигде не считается. 2 446 из 3 270 безкоординатных
попадают в свежий пул аналогов — 12.7% аналогов невидимы радиусному поиску.
Режем по «·», за которой идёт ЦИФРА (рейтинг «·4,9», счётчик «·2 отзыва»). По
любой «·» нельзя: разделитель района пишется «, 59 · р-н Академический» — за
точкой буква, и этот хвост _deglue_house_marker намеренно сохраняет (#1773).
Тест проверяет обе стороны плюс три прежних хвоста (CSS, метро, «от N мин.»).
Чинит только новые вставки: base.py пишет address = COALESCE(listings.address,
EXCLUDED.address), адрес при конфликте не перезаписывается осознанно (#2777).
Бэкфилл 1 123 существующих строк вынесен в #2814 для database-expert.
check-migration-lock-timeout.py требует lock_timeout только для NN >= 250 в
tradein — порог назначен по номеру аварийной миграции 250, которую затем
сняли с деплоя (#2792). Фактический максимум применённого на main — 239,
то есть весь диапазон 240-249 гейтом не проверяется вообще ("проверено
новых миграций: 0" = не проверено ни одного файла, не "все чисты"). Функция
scan() из самого гейта, прогнанная напрямую без порогового отсечения,
помечает ALTER TABLE в этом файле как блокирующий DDL без lock_timeout.
trade_in_estimates — самая горячая таблица стека (история, история
сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались открытые
транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка встаёт в
очередь перед новыми запросами приложения. DDL на 1058 строках мгновенный —
риск не в исполнении, а в ожидании чужой блокировки.
Добавлено SET LOCAL lock_timeout = '5s' сразу после BEGIN + объяснение в
шапке файла, почему оно здесь при том что гейт формально не требует —
чтобы не убрали как "лишнее". Порог гейта не трогаю — отдельный issue.
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.
МЕРА: у 8 компонентов витрины v2 проп data больше не имеет дефолта из fixtures.ts — при сбое передачи данных компонент обязан упасть на TS-ошибке, а не отрисовать выдуманные числа на платном экране оценки. Цепная правка в SectionOverlay (4 поля стали обязательными в такт с детьми).
Птица: удалены 6 осиротевших компонентов (ноль импортов подтверждён репо-wide), подчищены 2 ссылающихся комментария.
Проверено ревьюером: tsc --noEmit и next lint реально отработали на 91b460b1 (лог задачи 18031), vitest 32/264 зелёные (лог 18033); storybook в репозитории отсутствует вовсе — «unwired/storybook usage» как обоснование дефолтов никогда не имело потребителя; ui-preview/estimate использует v1-компоненты со своей локальной фикстурой и не задет.
Мина: 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 не тронуты.