После мержа #3577 (снапшоты «строка на изменение») прогон без БД — тот, что
гоняет deploy-tradein.yml перед сборкой, — завершается rc=1:
НЕУЧТЁННЫЙ ПРОПУСК (3): tests/test_2993_snapshot_change_only.py::...
В CI PR эти тесты шли против postgres-сервиса и проходили, поэтому
расхождение видно только на деплой-лэйне, где они законно пропускаются.
Объявлены с причиной, как соседние живые тесты (#3385, #3191).
Деплой-лэйн: 6465 passed, 68 skipped, rc=0, неучтённых пропусков 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tradein_canon_addr последним шагом оставлял только [а-я0-9], и «3/2», «10/7», «4/1»
схлопывались в «32», «107», «41». Матчер ГАР при фильтре по городу брал из общего
ключа строку с max(flat_count): дом «Высоцкого, 4/2» получил 725 квартир ГАР-дома «42».
Миграция 322: тело 311 без изменений, кроме последнего шага — «/» остаётся, если с обеих
сторон цифры. Новый ключ — только уточнение старого (без «/» он равен старому), поэтому
склеить новые пары правка не может. Прод 17.09 (read-only): ключ меняется у 2549 из
49 204 домов, распавшиеся канон-кластеры — 113 пар, ни одной ближе 250 м и ни одной с
общим ФИАС. Затем REINDEX функционального индекса и сброс 47 canon_addr-сопоставлений,
державшихся только на потерянной дроби (порог остановки 100).
Живой тест B в test_real_fias_pass_cross_guard_and_identity_carryover держался на паре
«32»/«3/2»: после 322 у неё разные ключи, и страж разных ФИАС в тесте не участвовал
(со снятым стражем тест оставался зелёным). Пара заменена на «ул.»/«улица».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.
Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
С 13.09 21:14 по 16.09 18:52 пять объявлений (10776456, 10775964, 10775945,
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
SPA-оболочкой ~381 КБ без блока контактов. Счётчика попыток на объявлении не было,
они возвращались в голову каждого снапшота и пятью недогрузами подряд выбивали
брейкер: 16 прогонов закончились ровно attempted=5 incomplete=5.
Миграция 321: listings.detail_incomplete_count / detail_incomplete_at.
yandex_detail_backfill пишет недогруз на объявление; снапшот не берёт карточку
сутки и не берёт совсем после трёх недогрузов (счётчик incomplete_given_up);
повторный недогруз уже известной карточки не двигает брейкер, первый — двигает,
защита от системного недогруза сохранена.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Конфликт только в tests/skip_allowlist.txt: обе стороны дописали блок в конец —
live-тесты пула прокси (#3299/#3310/#3404) и миграции 310 (#3385). Оставлены оба.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Конфликт только в tradein-mvp/backend/tests/skip_allowlist.txt: обе стороны
дописали блок в одно место (#3252 — тесты апсерта карточки ДомКлика, #3385 —
миграция 310). Оставлены оба блока. Номер миграции ветки 320 не пересекается
с main (максимум там 310).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI / changes краснел гейтом #2752: «блокирующий DDL без lock_timeout (REINDEX INDEX
gar_house_flats_canon_idx)». Добавлен SET LOCAL lock_timeout = '5s' сразу под BEGIN.
REINDEX оценён замером на проде 17.09 (только чтение):
- gar_house_flats 3,27 млн строк / 942 МБ heap (1,5 ГБ с индексами), индекс частичный
(flat_count > 0), 201 513 строк, 8,8 МБ;
- эквивалент перестройки (seq scan + tradein_canon_addr в один поток) 5,6 с — столько
таблица закрыта для записи и для планировщика любого запроса к ней; читают её только
ручной ГАР-загрузчик и ре-матч, пользовательский путь не задет;
- шаг S0b меняет ключ у 0 из 201 513 индексируемых строк: сегодня REINDEX ничего не
меняет, но оставлен в той же транзакции, что и тело функции, чтобы строки, загруженные
до деплоя, не остались со старым ключом. Прежнее обоснование («следующая загрузка ГАР»)
было неверным: новые строки и так получают ключ нового тела.
REINDEX CONCURRENTLY возможен (раннер гонит файл psql'ем в autocommit, образец 270), но
не взят: ждёт все транзакции базы старше своего снимка (сборщики держат idle in
transaction минутами), lock_timeout не терпит, оборванный оставляет невалидный *_ccnew,
тело функции коммитится раньше индекса.
Тест по значению на живом Postgres: 311 гоняется как в раннере, пока «загрузчик» держит
ROW EXCLUSIVE на gar_house_flats — миграция падает LockNotAvailable быстрее 10 с, тело
функции откатывается (xmin строки pg_proc не меняется). Без SET LOCAL тест красный:
DID NOT RAISE — миграция дождалась загрузчика.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Четыре live-теста из правок по ревью без БД пропускаются, а гейт conftest
роняет прогон с DSN-заглушкой на необъявленных пропусках (rc=1 при
«6226 passed» в сводке). Внесены в skip_allowlist.txt к остальным тестам #3299.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сверка парсера с живым __SSR_STATE__ трёх карточек (17.09) нашла три тихие дыры:
1. owners_count. egrnData отдаёт поля ЕГРН обёрткой {status, value}; int() от
словаря молча давал None. Прод: 0 из ~2970 карточек, обогащённых с 29.08,
против 5075 из 6296 у ручного прогона 18.07, который обёртку разворачивал.
Ключ egrnData.area подтверждён — гадания rosreestrArea/object_area удалены,
в raw_payload обёртка остаётся как есть (status = сверка с объявлением).
2. livingArea/kitchenArea = 0 у Домклика значит «не указано», а в колонку ложился
0.00 и затирал известное значение. Прод: 303 кухни и 84 жилые площади = 0,
только domklik. Разбор: _pos_float; строки: миграция 320 ставит NULL.
3. Жилую площадь и балконы стирал переобход выдачи: апсерт scraper_kit.base писал
их сырым EXCLUDED, а SERP Домклика и Авито этих полей не отдаёт. Прод: domklik,
переобойдённые после обогащения, living_area_m2 0 из 5217 (без переобхода
2990 из 4058); avito 1 из 8639 (4122 из 6505). COALESCE в SET, в гейте #2992
и на reconcile-пути.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Без БД 11 новых live-тестов пропускаются, и гейт conftest роняет прогон
(«НЕУЧТЁННЫЙ ПРОПУСК», rc=1 при «6226 passed» в сводке). В ci-tradein.yml они
идут по-настоящему: локальный прогон на Postgres со схемой из миграций —
6278 passed, 1 skipped, rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Все 7 скачков ×10 в yandex-истории цен на проде (17.09) — не «сдвиг на следующем
наблюдении», как предполагал issue, а синтетический посев из gate-поля
price.previous: нижняя точка стоит ровно на 24 ч раньше второй и записана тем же
батчем. Ошибочна нижняя цена (13–37 тыс/м² против 133–375 у верхней). Ещё 4 посева
того же рода — ×175…×1000 (цена в тысячах). Ветка писателя «история есть» на проде
не записала ни одной строки: смену цены раньше неё пишет триггер
record_listing_price_change, поэтому отлов «на следующем наблюдении» там поймал бы 0.
- yandex_price_history: price.previous, отличающаяся от текущей цены в 9.5 раза и
больше в любую сторону (или не > 0), не сеется; счётчик seed_rejected в логе.
- миграция 310: удаляет yandex-посевы по подписи батча (recorded_at совпадает,
change_time второй точки ровно +24 ч) с тем же порогом; lock_timeout 5s,
остановка при > 30 строк; на проде ровно 11 кандидатов, повторный прогон — 0.
- тесты по значению: посев отвергнут/сохранён на прод-ценах; настоящий файл
миграции на живом Postgres через временную таблицу.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Канон-ключ tradein_canon_addr не срезал район, приклеенный к номеру дома без
запятой (наследие слипа «29р-н» из #1773): «ул. Евгения Савкова, 17Ар-н
Академический» давал «евгениясавкова17арнакадемический», а «44Бр-н» терял
литеру (токен-срез принимал «бр» за бульвар). Двойник «ул. Евгения Савкова,17А»
с таким домом в один кластер не попадал, и перепись остатка его не видела.
Миграция 311: тело 147 плюс шаг «<цифра>[литера]р-н <название>» → «<цифра>[литера]»
сразу после lower. Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2»
превращается в «мк2». Прод 17.09 (read-only): канон меняется у 44 из 49 159
домов, у 20 появляется двойник в пределах 250 м, ГАР-сторона не затронута
(0 из 3,27 млн строк с шаблоном).
Тест по значению на функции, установленной миграциями, и сквозное слияние:
склеенный адрес и чистый двойник сливаются, «17» без литеры остаётся отдельным.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ФИАС-проход house_dedup_merge шёл без гео-стража (#2187: «общий ФИАС-UUID и
есть здание»). Посылка неверна: house_fias_id — ответ DaData на НАШУ строку
адреса, и «улица Кирова, 4» без города получает тот же UUID, что и дом в
другом городе. Прогон 05.09 (house_merge_log, merge_pass='fias'): 408 слияний,
129 дальше 250 м, 53 дальше 3 км, максимум 362 км, у 67 нет координат на одной
из сторон.
Теперь у обоих проходов один и тот же страж (250 м, координаты у обеих сторон),
параметр apply_geo_guard удалён — асимметрию нельзя вернуть флагом.
Тест по значению на живой Postgres: тот же ФИАС в 5 км и с пустыми координатами
не сливается, тот же ФИАС в 10 м сливается, журнал пишет geo_guard=true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача landing_showcase_deals не запускалась вообще: строки в
scrape_schedules не было (0 строк по '%showcase%' на проде 12.09), а в
реестре product_handlers — обработчика. Пересчёт был ручным шагом, и
лэндинг показывал прогон от 30.08 — тринадцать суток.
Что сделано:
- Handler `landing_showcase_deals` в product_handlers: тело задачи
писалось под `python -m` и про run_id не знает, поэтому done/failed
ставит обработчик (как у refresh_search_matview).
- Миграция 303 сеет расписание: enabled=true, окно 06:00–07:00 UTC,
interval_days=1. Такт суточный не из-за данных — сделки Росреестра
квартальные, — а из-за кода: прогноз считает тот же спайн оценщика,
что и боевой расчёт, и любой деплой меняет числа на витрине, не
трогая ни одной сделки.
- Эта же строка заводит витрину в СУЩЕСТВУЮЩИЙ монитор свежести:
сводка просроченных источников (emit_stale_digest, #2670) ходит по
включённым расписаниям и бьёт ERROR → GlitchTip, когда источник
молчит дольше 3× своего такта. Своего монитора не заводим: витрина
была невидима не потому, что сводка не умеет про неё говорить, а
потому, что источника для сводки не существовало.
- На странице под таблицей — дата прогона рядом со счётчиками:
«Витрина пересчитана 30.08.2026». computed_at ручка /showcase отдавала
и раньше, фронт его не показывал; даты нет — предложения нет.
Тесты: обработчик резолвится тем же resolve_handler, что и боевой
_dispatch; миграция на живой БД реально кладёт строку и не задваивает
её при повторе; настоящий запрос сводки видит витрину и отдаёт её
просроченной после 3× такта (число тактов — литерал из приёмки, не
константа кита: взятое из неё ожидание уезжало вместе с ней и держало
тесты зелёными при факторе 3650).
Closes#3469
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
На боевой БД `statement_timeout`, `lock_timeout` и
`idle_in_transaction_session_timeout` равны 0, а у движка `app/core/db.py` не
было `connect_args` вовсе. После #3444/#3449 шаги БД на пути `/estimate` идут
через обёртку, которая при отмене по бюджету ДОЖИДАЕТСЯ своего потока (иначе он
остаётся сиротой в общей `Session`) — ожидание верное, но его верхняя граница
равна длительности самого запроса, а у запроса границы не было. Один
`ACCESS EXCLUSIVE` на таблице → четыре повисших запроса → `_ESTIMATE_CONCURRENCY`
исчерпан → `/estimate` отдаёт 429 всем остальным.
Потолок ставится на КОННЕКТЕ (libpq `options`), а не в обёртке: таймаут в
обёртке вернул бы ровно ту сироту, ради которой писался #3449.
statement_timeout = 30 с: выше самого длинного ОБЪЯВЛЕННОГО бюджета `/estimate`
(20 с, `estimate_avito_imv_timeout_s`) в 1.5 раза и в 7 раз выше самого долгого
ЗАМЕРЕННОГО запроса через этот движок (4.27 с, `pg_stat_statements` на проде за
16 суток), но конечен. lock_timeout = 5 с: та же величина, что у миграций
проекта, и больше `deadlock_timeout` (1 с на проде).
`idle_in_transaction_session_timeout` намеренно не трогаем: тем же движком живёт
планировщик, а его свипы держат транзакцию открытой всё время внешнего HTTP
(замер: живая сессия `idle in transaction` 29 с).
Задачи планировщика проверены, а не предположены: у `listing_source_snapshot`
свой `SET LOCAL statement_timeout = 900000`, и тест доказывает, что `SET LOCAL`
ПЕРЕКРЫВАЕТ сессионный потолок и не течёт за свою транзакцию. Самая долгая
чисто-БД задача по `scrape_runs` за 14 суток укладывается в 9.7 с целиком;
единственный запрос длиннее 20 с на всей БД (`REFRESH MATERIALIZED VIEW
CONCURRENTLY`, 30.85 с) идёт мимо движка — по своему сырому psycopg-соединению.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Правка меняет две вещи разом, обе намеренно.
1. Равенство по дате -> «последняя строка не позже якоря».
listing_source_snapshots переходит на модель «строка на изменение».
В ней equality-join теряет 95.6% пар (на 2026-08-20 изменениями являются
4458 строк из 101795): выборка для percentile_disc схлопывается до n=3-4,
квантиль вырождается в максимум из трёх чисел, TTL обваливается —
domklik 28->14 (под снятие сразу 128 активных строк), yandex 44->30,
avito 13->10.
Дыры в суточной истории ломали equality-join и до перехода: на проде
03-14.06 (12 суток подряд), 03-04.07, 12.07, 26.07, 30-31.07, 01.08 —
там n_pairs=0, floor_days=NULL и TTL молча оставался как задан.
2. Глобальный max(snapshot_date) -> максимум внутри источника.
Старый подзапрос брал максимум по ВСЕЙ таблице, не скоупленный по
listing_source_id: источник, чья история короче общей, выпадал из
выборки целиком. LATERAL ищет предшественника по строке.
Замер на живом проде 2026-08-23 (health_window_days=3): обе формы дают
побитово одинаковый результат на всех четырёх источниках —
avito n=765 пол=49.7, cian n=1226 пол=81.6, domklik n=565 пол=35.7,
yandex n=3856 пол=87.3. Сегодня суточная джоба пишет строку для каждого
источника каждый день, поэтому глобальный максимум совпадает с максимумом
каждого источника, и пункт 2 — no-op на текущих данных. Расхождение
проявится только на дырах и после перехода на change-only.
Плюс floor_n_pairs в counters — наблюдательность для будущего гейта
деградации пола, сейчас ничего не блокирует.
FROM..WHERE вынесен в _revisit_floor_from_where_sql, чтобы count(*) и
percentile_disc гарантированно шли по одному срезу.
main принёс PR #2751 (ruff-шаг в ci-tradein.yml/backend-tests) параллельно с
fetch-depth: 0 из этой ветки в том же job'е — не противоречат друг другу,
слились автоматически.
Единственное ручное разрешение — _manifest_applied.txt (modify/delete):
main дописал файл, ветка его удаляет. Разрешение — удаление, это и есть
предмет PR: гейт номеров миграций берёт эталон из git (origin/main), а не
из ручного манифеста, который отставал и по построению не мог покраснеть
(#2683, живой инцидент 15.08 — коллизия 264_ между двумя независимыми ветками).
Повторная проверка /coverage закрыла оба MAJOR из #2894, но выявила три
новых дефекта:
1. Город больше не резолвится из моды listings.city найденной когорты —
эта колонка хранит город SWEEP-контекста скрейпера (миграция 196), не
геокод адреса объявления. Замер на проде: 90/90 строк в радиусе 1000м
вокруг Берёзовского имеют city='Екатеринбург', 74/74 вокруг Ревды —
city='Первоуральск'. Города-спутники из COVERAGE_GREEN/YELLOW_CITIES были
физически недостижимы. Город теперь резолвится детерминированно по
lat/lon запроса — ближайший центроид из статичной константы (8 городов,
рядом с ручкой, не в БД — comment объясняет почему) в пределах 25 км.
city_hint остаётся в схеме (фронт его шлёт для соседних ручек), но чисто
информационный — на порог/статус не влияет.
2. test_max_age_outlier_days_passed_to_sql проверял подстроку, которая
встречается в SQL дважды (count и percentile_cont) — мутация «убрать
FILTER у percentile_cont, оставив у count» проходила зелёной. Добавлен
живой поведенческий тест (вставляет когорту + выброс days_on_market=4000,
проверяет что медиана не сдвигается) — ловит эту мутацию (подтверждено:
median 8→9 при мутации).
3. _live_session() вызывался в pytest.mark.skipif на этапе сбора тестов и
создавал никогда не закрываемый Session, плюс дублировался в теле теста.
Заменено на _live_db_available() (open+close голого connection) для
skipif и pytest-фикстуру live_session с гарантированным close/dispose.
4. Nit: пустая когорта в поддерживаемом городе отдавала status=not_covered
вместе с ненулевым threshold — противоречило докстрингу
CoverageProbeResponse.threshold ("0, когда порог неприменим"). threshold
теперь всегда 0 при not_covered, независимо от причины.
Independent review found two MAJOR defects in POST /api/v1/trade-in/coverage:
MAJOR-1: the probe cohort WHERE clause was missing three predicates present
in estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision !=
'city', price_rub > 0) — the free probe could answer "ok" at points where
the paid estimator's own 1000m radius tier sees zero real analogs. Prod
example: 56.868904/60.837955, 2 rooms, 50 m2 gave n_listings=22/status=ok
while the estimator's cohort at the same radius was 0 (all 54 rows were
novostroyki). Added the three predicates verbatim from estimator.py, plus
both a static SQL-text regression test and a real-Postgres integration test
(skip_allowlist.txt, same _live_session() pattern as test_gar_flats_loader)
that inserts novostroyka/geo_precision=city/price=0 rows and asserts they
are not counted.
MAJOR-2: median_listing_age_days was computed from days_on_market, which on
prod is populated almost exclusively by one source (yandex) — thin cohorts
produced a "median" over 1-2 listings. Added n_with_age to the response
(honest count of listings the median is based on); median is now null below
COVERAGE_MIN_AGE_SAMPLES=5, and values above COVERAGE_MAX_AGE_DAYS=365 (near
-certainly dead listings, per prod: 15% of fresh yandex rows exceed 365d,
max 4261d) are excluded as outliers before the percentile is computed.
MINOR: city_hint was trusted at face value and echoed back verbatim — a
client could pass city_hint="Екатеринбург" with coordinates in Серов and get
threshold=8/status=ok. _resolve_coverage_city now prioritizes the SQL
cohort's mode city (ground truth) over the client hint, falling back to hint
only when the cohort is empty (where status is forced not_covered anyway).
Unmatched cities no longer echo the raw client string in the city field.
Во втором коммите PR отдельный `git fetch origin main` был снят (из
job-контейнера git.gendsgn.ru недостижим, run 6977), эталон даёт сам checkout с
fetch-depth: 0. Комментарий про «тянут main отдельным шагом» остался и читался
бы дальше как факт.
Конфликт modify/delete по tradein-mvp/backend/data/sql/_manifest_applied.txt
разрешён удалением — удаление и есть предмет PR. За трое суток в main дописали
три имени (240/250/251); дописывать их некуда: файла больше нет, а гейт
tests/test_migration_numbering.py берёт эталон применённого из origin/main.
Проверено, что удаление ничего не оставляет без потребителя: манифест не *.sql,
цикл миграций в deploy-tradein.yml и bootstrap схемы в ci-tradein.yml берут
glob '*.sql', новый scripts/check-migration-lock-timeout.py — тоже.
Замер дрейфа на 27e199e3: 216 имён в манифесте против 232 файлов, отставание
16 (было 15 на 07.08). Старый гейт на этом дереве: 4 passed.
# Conflicts:
# tradein-mvp/backend/data/sql/_manifest_applied.txt
_manifest_applied.txt по построению не мог покраснеть. Тест считал «новым»
любой файл, которого нет в списке, а новые файлы от списка освобождены
(докстринг test_manifest_covers_all_but_new_files: «НЕ требует, чтобы новый
файл уже был в manifest»). Забытое имя и новая миграция PR для гейта — одно и
то же, поэтому дрейф был не пропуском проверки, а её штатным исключением.
Замер на main 2026-08-07: 15 имён не дописано, все четыре теста зелёные —
через сутки после того, как #2692 догнал список руками.
Список при этом был лишь копией того, что git и так знает: deploy-tradein.yml
применяет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP, то есть «файл
доехал до main» и есть «имя закреплено на проде». Ведём эталон в git — и
дрейфовать становится нечему.
Кросс-ветковая дыра закрыта тем же ходом: номер нового файла сверяется с
ПОЛНЫМ origin/main, а не с рабочим деревом, поэтому коллизия с миграцией,
смерженной после ветвления, находится. Проверено на живом PR #2754
(234_trade_in_estimates_retain_until против 234_scrape_runs_ban_kind_unknown
из main): старый гейт зелёный, новый красный.
Удаление/переименование применённой миграции сверяется с ТОЧКОЙ ВЕТВЛЕНИЯ, а
не с origin/main: иначе ветка недельной давности краснела бы за чужие
миграции. Проверено — ветка от 2026-07-30 при +43 миграциях в main зелёная.
CI: checkout переведён на fetch-depth 0 + отдельный fetch main. Этот Forgejo
не публикует refs/pull/N/merge (1620 */head, ноль */merge), а на depth=1 нет
ни origin/main, ни общего предка — без этого гейту не с чем сверять, и он
намеренно красный, а не тихо пропущенный.
Контракт сведён к одной формулировке — докстринг test_migration_numbering.py;
шапка манифеста, правило 3, хвост манифеста и рецепт из .claude/rules
удалены или заменены ссылкой. Заодно исправлен сам рецепт: `git ls-tree` без
`-r` печатает каталог, а не файлы.
Refs #2683