Схлопывание дублей домов: ФИАС-проход больше не сливает дома из разных городов, а «17Ар-н Академический» находит своего двойника #3558
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3558
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/houses-dedup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Два дефекта в еженедельном схлопывании дублей домов МЕРЫ (
house_dedup_merge). Первый: ФИАС-проход сливал дома без проверки расстояния. Второй: канон-ключ не видел двойников у адресов, где район приклеен к номеру дома. Два коммита, по одному на задачу.#2690: ФИАС-проход сливал дома из разных городов
Что было. В #2187 у ФИАС-прохода сняли гео-страж на 250 м с доводом «общий ФИАС-UUID — это и есть здание». Но в том же модуле (раздел KEY, замер 10.08) установлено, что
house_fias_id— это ответ DaData на нашу же строку адреса. Если в адресе нет города, «улица Кирова, 4» получает тот же UUID, что дом с таким адресом в другом городе. 10.08 проход сливал 0 домов, и асимметрия никак не проявлялась. 05.09 после массового обогащения ФИАС она проявилась.Улики (прод,
house_merge_log, только чтение, 17.09). Батчe7d38da5-20ee-4ed9-84b2-9bbaeba039c2, прогон 6075 от 05.09. У ФИАС-прохода 408 слияний. Разбивка по расстоянию:Примеры: «р-н Синарский, улица Кирова, 4» (Каменск-Уральский) слит в «улица Кирова, 4» за 362 км, «улица Попова, 15» — за 313 км. Внутри 3 км тоже есть заведомо разные дома: «улица Азина, 31» → «улица Печерская, 4» (1136 м), «ул. Очеретина, 13» → «улица Серафимы Дерябиной, 30» (2020 м). У канон-прохода за все 6 прогонов с журналом (08.08–12.09) ни одного слияния дальше 250 м нет.
Что сделано. У обоих проходов теперь один страж: координаты есть у обеих сторон и расстояние не больше 250 м. Параметр
apply_geo_guardудалён, чтобы асимметрию нельзя было вернуть одним флагом. В журнале у обоих проходовgeo_guard=true. Докстринги переписаны: посылка #2187 опровергнута данными.Почему 250 м, а не 3 км (порог из триажа). Порог 3 км подобран по одному прогону. Он всё равно пропускает Азина→Печерская и Очеретина→Дерябиной, а слияния без координат оставляет разрешёнными. По ним страж не может ничего сказать, и канон-проход такие пары не сливает. Цена: пары в 250 м – 3 км, большей частью шум геокодера внутри Екатеринбурга («ул. Баумана, 22Б» и «р-н Орджоникидзевский, мкр. Эльмаш, улица Баумана, 22Б», 256 м), больше не будут сливаться ФИАС-проходом. Это тот же потолок «канон + 250 м», что зафиксирован в #2690 10.08. Если владелец решит ослабить страж для ФИАС, это одна строка в
_mapping_sql, но тогда нужен отдельный порог и свой тест.Сверка до мержа на проде (только чтение, 17.09). Отрендеренный новый маппинг ФИАС-прохода сейчас сливает 0 пар. Правка защищает от следующего массового обогащения ФИАС, текущая неделя от неё не меняется.
#1772: «17Ар-н Академический» не находил своего двойника
Что было. После слипа «29р-н» из #1773 в базе остались дома, у которых район приклеен к номеру без запятой.
tradein_canon_addrсрезает «р-н …» только как отдельный сегмент, поэтому:евгениясавкова17арнакадемический, а двойник «ул. Евгения Савкова,17А» —евгениясавкова17а;евгениясавкова44накадемический.Разный канон означает, что дома не попадают в один кластер, и перепись остатка (
residual_*) их тоже не видит. Поэтому эти фрагменты не отражались ни в каких счётчиках.Что сделано. Миграция
311_canon_strip_district_glued_to_house_number.sql: тело функции из 147 без изменений (md5 тела 147 совпадает с функцией на проде) плюс один шаг сразу послеlower:([0-9][а-я]?)р-н [^,]*→\1. ЗатемREINDEX INDEX gar_house_flats_canon_idx, по правилу из 147.Якорь на цифре обязателен. Без него срез «р-н …» портит «мкр-н»: «Мкр-н Кутузовский, 2» превращается в
мк2. На проде такой срез менял канон у 1024 домов вместо 44.Замер на проде (только чтение, выражение подставлено inline, 17.09):
gar_house_flatsшаблон встречается в 0 из 3,27 млн строк, так что сопоставление с ГАР не меняется;Почему задача #1772 этим PR не закрывается. Тем же замером найден второй промах канона того же класса: префикс «мкр-н X,». Шаг S4b знает «мкр.», но не «мкр-н», и получается
нбарабасоветская14. Канон меняется у 659 домов, из них у 72 есть двойник в пределах 250 м. Это в 15 раз больше и задевает сопоставление с ГАР, поэтому нужен отдельный замер. В эту правку не включено.Доработка 17.09: красный
CI / changes(гейт #2752)Что было. Джоба
CI / changesпадала на шаге «Guard: блокирующий DDL без lock_timeout»:Что сделано.
SET LOCAL lock_timeout = '5s';сразу подBEGIN, шапка миграции дополнена замером и выбором. Гейт локально:✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 67), rc=0.Оценка самого REINDEX (прод, только чтение, 17.09 ~09:20 UTC).
gar_house_flatsстрокgar_house_flats_canon_idx(частичный,flat_count > 0)tradein_canon_addrв один потокОбычный REINDEX берёт SHARE на таблицу и ACCESS EXCLUSIVE на индекс, то есть на эти секунды ждёт любой запрос к
gar_house_flats. Пишут и читают её только ручной ГАР-загрузчик и ре-матч (app/services/gar_flats_loader.py). Вscrape_schedulesих нет, вpg_stat_statementsс 27.08 есть только INSERT загрузчика и ручные замеры. Пользовательский путь не задет.Сегодня REINDEX ничего не меняет, потому что ни у одной строки ключ не меняется. Оставлен в той же транзакции, что и новое тело: замер не покрывает строки, загруженные до деплоя. Прежнее обоснование в шапке («следующая загрузка ГАР») было неверным, новые строки и так получают ключ нового тела. Исправлено.
Почему не
REINDEX CONCURRENTLY. Деплой исполняет файлpsql -v ON_ERROR_STOP=on < fileбез--single-transaction, то есть в autocommit по операторам (deploy-tradein.yml, цикл миграций; CI собирает схему так же). ВнеBEGIN/COMMITCONCURRENTLY возможен, образец — миграция 270. Не взят по трём причинам:idle in transactionминутами (17.09 в момент замера — 3 мин).lock_timeoutон не терпит, поэтому деплой висел бы без ограничения;*_ccnew: деплой краснеет на шаге 3b, и чистить приходится руками на проде;Ради ~5 с лока на таблице пакетного загрузчика это хуже.
Тест по значению
test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing(живой PostGIS). Файл 311 исполняется так же, как в раннере (autocommit, BEGIN/COMMIT внутри), пока «загрузчик» держитROW EXCLUSIVEнаgar_house_flatsи отпускает через 12 с. Проверяется, что миграция падаетLockNotAvailableбыстрее 10 с, аxminстрокиpg_procне меняется, то есть тело функции откатилось вместе с REINDEX.Фальсификация. Из копии миграции убрана строка
SET LOCAL lock_timeout(исходник сохранён в scratchpad, после прогона восстановлен,diff -qчист):Без таймаута миграция дождалась «загрузчика» (12 с) и прошла.
Ветка не перебазирована, а слита со свежим
origin/main: она уже опубликована, push без--force. Дерево после слияния побайтно совпадает с деревом после пробного rebase (git diffпуст), и прогоны ниже сделаны на нём.Тесты
tests/test_house_dedup_merge.py+tests/test_gar_flats_loader.pyна живом PostGIS (схема собрана с нуля из всехdata/sql/*.sql, включая 311, 0 невалидных индексов): 76 passed, rc=0 (до слияния с main; вtest_house_dedup_merge.py48 тестов).tradein-mvp/backendдо слияния с main: с живой базой 6267 passed, 1 skipped, rc=0; с заглушечным DSN 6224 passed, 44 skipped, rc=0.a130303c), схема пересобрана с нуля (289 файлов): с живой базой 6414 passed, 4 failed, 1 skipped; с заглушечным DSN 6369 passed, 4 failed, 46 skipped. Все 4 падения не из этого PR и так же красные на чистомorigin/maina130303c:test_3466_corridor_tier_a.py×2 иtest_estimator_radius_floor.py×2,AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'/estimate_corridor_clamp_min_n(стык #3554 и #3556).ruff check app tests: rc=0.ruff format --checkпо изменённым файлам: rc=0.test_real_fias_pass_keeps_geo_guard(заменяетtest_real_fias_pass_ignores_geo_guard). Тот же ФИАС в 5 км и с пустыми координатами не сливается, тот же ФИАС в 10 м сливается. В журнале ровно("fias", True, 900034, 900035, 10).test_real_canon_strips_district_glued_to_house_number. Проверяет значения функции, установленной миграциями:17Ар-н→…17а,44Бр-н→…44б, «17» ≠ «17А»,Мкр-н Кутузовский, 2→нкутузовский2. Сквозной прогон: склеенный адрес сливается с чистым двойником, «17» без литеры в той же точке остаётся отдельным домом.Фальсификация
#2690. С фикса снят страж у ФИАС-прохода (копия исходника в scratchpad, после прогона восстановлена,
diff -qчист):Без стража слились дом без координат (900031) и дом в 5 км (900033).
#1772. Шаг S0b в миграции подменён на не срабатывающий регэксп, миграция применена заново:
Отдельно снят якорь на цифре (
()р-н [^,]*):Оба раза миграция восстановлена из копии (
diff -qчист), применена, тест снова зелёный.Деплой
CREATE OR REPLACE FUNCTIONплюсREINDEX INDEX gar_house_flats_canon_idxв одной транзакции подSET LOCAL lock_timeout = '5s'. Записи в данные нет. REINDEX держит таблицуgar_house_flatsзакрытой для записи и планировщика около 5 с (замер ниже). Второй гейт: в момент деплоя не должна идти загрузка ГАР —SELECT pid, now()-xact_start, left(query,60) FROM pg_stat_activity WHERE query ILIKE '%gar_house_flats%' AND pid <> pg_backend_pid();пусто. Если идёт, миграция упадёт через 5 с целиком (тело функции откатится), деплой красный, повторить после загрузки.scrape_runs.status='running'. 17.09 около 07:10 UTC шли 3 обхода (domclick Москва, avito ЕКБ, avito Верхняя Пышма).house_dedup_mergeпо расписанию: 2026-09-19 04:49 UTC. Если мерж будет позже, приёмка сдвигается на первый прогон после деплоя.Приёмка на проде (критерии записаны до факта)
Для первого прогона
house_dedup_mergeпосле деплоя (ожидается 19.09 04:49 UTC, иначе 26.09):SELECT count(*) FROM house_merge_log WHERE merge_pass='fias' AND merged_at > '<время деплоя>' AND (distance_m IS NULL OR distance_m > 250 OR NOT geo_guard)возвращает 0.SELECT tradein_canon_addr('Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический')возвращаетевгениясавкова17а. В журнале этого прогона у канон-прохода есть пары, гдеloser_idилиkeeper_idвходит в 44 дома со склеенным адресом: ожидается около 20, всеdistance_m ≤ 250. Например, 7617 и 11215 (Академика Вавилова, 9).residual_mergeableв counters прогона остаётся 0.Отдельный шаг для владельца: откат 53 межгородских ФИАС-слияний от 05.09
Требует записи в прод, поэтому здесь не выполнялся. Делать после деплоя, иначе следующий прогон без стража сольёт их снова. Все 53 победителя живы. У одного из них после 05.09 были новые слияния:
house_merge_undoсообщит статусом, что восстановить не удалось.Остальные 143 ФИАС-слияния, которые новый страж бы запретил (250 м – 3 км и без координат), в большинстве похожи на настоящие дубли. Откатывать их оптом не предлагаю, только по ручной сверке.
Refs #2690, Refs #1772
🤖 Generated with Claude Code
Доработка запушена, голова
e3616e36. Раздел «Доработка 17.09» в описании.CI на этой голове:
CI / changesзелёный, гейт lock_timeout проходит (был красным наe2b1a8ad);CI Trade-In / backend-tests(run 11709): 6417 passed, 4 failed. Все 4 не из этого PR:test_3466_corridor_tier_a.py×2 иtest_estimator_radius_floor.py×2 сAttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack' / 'estimate_corridor_clamp_min_n'. Воспроизведено локально на чистомorigin/maina130303c, это стык #3554 и #3556. Схема в CI собралась, невалидных индексов нет.🤖 Generated with Claude Code