Схлопывание дублей домов: ФИАС-проход больше не сливает дома из разных городов, а «17Ар-н Академический» находит своего двойника #3558

Merged
bot-backend merged 5 commits from fix/houses-dedup into main 2026-09-17 11:17:38 +00:00
Collaborator

Два дефекта в еженедельном схлопывании дублей домов МЕРЫ (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 слияний. Разбивка по расстоянию:

расстояние слияний
до 250 м 212
250 м – 1 км 63
1–3 км 13
3–10 км 6
больше 10 км 47
нет координат у одной из сторон 67

Примеры: «р-н Синарский, улица Кирова, 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А» — евгениясавкова17а;
  • в «44Бр-н Академический» литера пропадала: срез типов улиц принимал «бр» за бульвар, получалось евгениясавкова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):

  • канон меняется у 44 из 49 159 домов, и это ровно те 44 дома, у которых район приклеен к номеру;
  • у 20 из них появляется двойник в пределах 250 м (0–222 м). Во всех парах это тот же адрес в другом написании, разных ФИАС нет. Пример: «Академика Вавилова, 9р-н Академический» (705 объявлений) и «улица Академика Вавилова, 9» (105), 0 м;
  • в gar_house_flats шаблон встречается в 0 из 3,27 млн строк, так что сопоставление с ГАР не меняется;
  • отрендеренный канон-маппинг с новым каноном даёт 338 слияний против 318 со старым: +20, максимум 242 м.

Почему задача #1772 этим PR не закрывается. Тем же замером найден второй промах канона того же класса: префикс «мкр-н X,». Шаг S4b знает «мкр.», но не «мкр-н», и получается нбарабасоветская14. Канон меняется у 659 домов, из них у 72 есть двойник в пределах 250 м. Это в 15 раз больше и задевает сопоставление с ГАР, поэтому нужен отдельный замер. В эту правку не включено.

Доработка 17.09: красный CI / changes (гейт #2752)

Что было. Джоба CI / changes падала на шаге «Guard: блокирующий DDL без lock_timeout»:

::error file=tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql::блокирующий DDL без lock_timeout (REINDEX INDEX gar_house_flats_canon_idx). Добавь первой строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` ...

Что сделано. SET LOCAL lock_timeout = '5s'; сразу под BEGIN, шапка миграции дополнена замером и выбором. Гейт локально: ✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 67), rc=0.

Оценка самого REINDEX (прод, только чтение, 17.09 ~09:20 UTC).

что значение
gar_house_flats строк 3 271 049
heap / всего с индексами 942 МБ / 1 505 МБ
gar_house_flats_canon_idx (частичный, flat_count > 0) 201 513 строк, 8,8 МБ
эквивалент перестройки: seq scan + tradein_canon_addr в один поток 5,6 с (скан 0,96 с, ключи ~4,6 с)
строк, у которых шаг S0b меняет ключ 0 из 201 513

Обычный 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/COMMIT CONCURRENTLY возможен, образец — миграция 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 чист):

E               Failed: DID NOT RAISE LockNotAvailable
tests/test_house_dedup_merge.py:1102: Failed
FAILED tests/test_house_dedup_merge.py::test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing
1 failed, 47 deselected in 13.38s

Без таймаута миграция дождалась «загрузчика» (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.py 48 тестов).
  • Весь сьют tradein-mvp/backend до слияния с main: с живой базой 6267 passed, 1 skipped, rc=0; с заглушечным DSN 6224 passed, 44 skipped, rc=0.
  • После слияния со свежим main (a130303c), схема пересобрана с нуля (289 файлов): с живой базой 6414 passed, 4 failed, 1 skipped; с заглушечным DSN 6369 passed, 4 failed, 46 skipped. Все 4 падения не из этого PR и так же красные на чистом origin/main a130303c: 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 чист):

E           AssertionError: [900030, 900032, 900035]
FAILED tests/test_house_dedup_merge.py::test_both_passes_carry_the_geo_guard
FAILED tests/test_house_dedup_merge.py::test_both_passes_share_one_pipeline_no_copy_paste
FAILED tests/test_house_dedup_merge.py::test_real_fias_pass_keeps_geo_guard
3 failed, 43 passed

Без стража слились дом без координат (900031) и дом в 5 км (900033).

#1772. Шаг S0b в миграции подменён на не срабатывающий регэксп, миграция применена заново:

E           AssertionError: assert 'евгениясавко...академический' == 'евгениясавкова17а'
E             + евгениясавкова17арнакадемический
FAILED tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number

Отдельно снят якорь на цифре (()р-н [^,]*):

E           AssertionError: assert 'мк2' == 'нкутузовский2'

Оба раза миграция восстановлена из копии (diff -q чист), применена, тест снова зелёный.

Деплой

  • Миграция 311: 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 с целиком (тело функции откатится), деплой красный, повторить после загрузки.
  • Пересоздаются контейнеры образа backend: tradein-backend и tradein-scraper (в нём планировщик). Гейт: перед деплоем проверить 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):

  1. #2690: 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.
  2. #1772: на проде 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 сообщит статусом, что восстановить не удалось.

BEGIN;
SELECT * FROM house_merge_undo(
  'e7d38da5-20ee-4ed9-84b2-9bbaeba039c2',
  ARRAY(SELECT loser_id FROM house_merge_log
        WHERE batch_id = 'e7d38da5-20ee-4ed9-84b2-9bbaeba039c2'
          AND merge_pass = 'fias' AND distance_m > 3000)
);
-- проверить out_status по всем 53 строкам, затем COMMIT или ROLLBACK

Остальные 143 ФИАС-слияния, которые новый страж бы запретил (250 м – 3 км и без координат), в большинстве похожи на настоящие дубли. Откатывать их оптом не предлагаю, только по ручной сверке.

Refs #2690, Refs #1772

🤖 Generated with Claude Code

Два дефекта в еженедельном схлопывании дублей домов МЕРЫ (`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 слияний. Разбивка по расстоянию: | расстояние | слияний | |---|---:| | до 250 м | 212 | | 250 м – 1 км | 63 | | 1–3 км | 13 | | 3–10 км | 6 | | больше 10 км | 47 | | нет координат у одной из сторон | 67 | Примеры: «р-н Синарский, улица Кирова, 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А» — `евгениясавкова17а`; - в «44Бр-н Академический» литера пропадала: срез типов улиц принимал «бр» за бульвар, получалось `евгениясавкова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):** - канон меняется у 44 из 49 159 домов, и это ровно те 44 дома, у которых район приклеен к номеру; - у 20 из них появляется двойник в пределах 250 м (0–222 м). Во всех парах это тот же адрес в другом написании, разных ФИАС нет. Пример: «Академика Вавилова, 9р-н Академический» (705 объявлений) и «улица Академика Вавилова, 9» (105), 0 м; - в `gar_house_flats` шаблон встречается в 0 из 3,27 млн строк, так что сопоставление с ГАР не меняется; - отрендеренный канон-маппинг с новым каноном даёт 338 слияний против 318 со старым: +20, максимум 242 м. **Почему задача #1772 этим PR не закрывается.** Тем же замером найден второй промах канона того же класса: префикс «мкр-н X,». Шаг S4b знает «мкр.», но не «мкр-н», и получается `нбарабасоветская14`. Канон меняется у 659 домов, из них у 72 есть двойник в пределах 250 м. Это в 15 раз больше и задевает сопоставление с ГАР, поэтому нужен отдельный замер. В эту правку не включено. ## Доработка 17.09: красный `CI / changes` (гейт #2752) **Что было.** Джоба `CI / changes` падала на шаге «Guard: блокирующий DDL без lock_timeout»: ``` ::error file=tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql::блокирующий DDL без lock_timeout (REINDEX INDEX gar_house_flats_canon_idx). Добавь первой строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` ... ``` **Что сделано.** `SET LOCAL lock_timeout = '5s';` сразу под `BEGIN`, шапка миграции дополнена замером и выбором. Гейт локально: `✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 67)`, rc=0. **Оценка самого REINDEX (прод, только чтение, 17.09 ~09:20 UTC).** | что | значение | |---|---:| | `gar_house_flats` строк | 3 271 049 | | heap / всего с индексами | 942 МБ / 1 505 МБ | | `gar_house_flats_canon_idx` (частичный, `flat_count > 0`) | 201 513 строк, 8,8 МБ | | эквивалент перестройки: seq scan + `tradein_canon_addr` в один поток | **5,6 с** (скан 0,96 с, ключи ~4,6 с) | | строк, у которых шаг S0b меняет ключ | **0 из 201 513** | Обычный 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/COMMIT` CONCURRENTLY возможен, образец — миграция 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` чист): ``` E Failed: DID NOT RAISE LockNotAvailable tests/test_house_dedup_merge.py:1102: Failed FAILED tests/test_house_dedup_merge.py::test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing 1 failed, 47 deselected in 13.38s ``` Без таймаута миграция дождалась «загрузчика» (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.py` 48 тестов). - Весь сьют `tradein-mvp/backend` до слияния с main: с живой базой **6267 passed, 1 skipped**, rc=0; с заглушечным DSN **6224 passed, 44 skipped**, rc=0. - После слияния со свежим main (`a130303c`), схема пересобрана с нуля (289 файлов): с живой базой **6414 passed, 4 failed, 1 skipped**; с заглушечным DSN **6369 passed, 4 failed, 46 skipped**. Все 4 падения не из этого PR и так же красные на чистом `origin/main` `a130303c`: `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` чист): ``` E AssertionError: [900030, 900032, 900035] FAILED tests/test_house_dedup_merge.py::test_both_passes_carry_the_geo_guard FAILED tests/test_house_dedup_merge.py::test_both_passes_share_one_pipeline_no_copy_paste FAILED tests/test_house_dedup_merge.py::test_real_fias_pass_keeps_geo_guard 3 failed, 43 passed ``` Без стража слились дом без координат (900031) и дом в 5 км (900033). **#1772.** Шаг S0b в миграции подменён на не срабатывающий регэксп, миграция применена заново: ``` E AssertionError: assert 'евгениясавко...академический' == 'евгениясавкова17а' E + евгениясавкова17арнакадемический FAILED tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number ``` Отдельно снят якорь на цифре (`()р-н [^,]*`): ``` E AssertionError: assert 'мк2' == 'нкутузовский2' ``` Оба раза миграция восстановлена из копии (`diff -q` чист), применена, тест снова зелёный. ## Деплой - Миграция 311: `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 с целиком (тело функции откатится), деплой красный, повторить после загрузки. - Пересоздаются контейнеры образа backend: tradein-backend и tradein-scraper (в нём планировщик). **Гейт:** перед деплоем проверить `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): 1. **#2690:** `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**. 2. **#1772:** на проде `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` сообщит статусом, что восстановить не удалось. ```sql BEGIN; SELECT * FROM house_merge_undo( 'e7d38da5-20ee-4ed9-84b2-9bbaeba039c2', ARRAY(SELECT loser_id FROM house_merge_log WHERE batch_id = 'e7d38da5-20ee-4ed9-84b2-9bbaeba039c2' AND merge_pass = 'fias' AND distance_m > 3000) ); -- проверить out_status по всем 53 строкам, затем COMMIT или ROLLBACK ``` Остальные 143 ФИАС-слияния, которые новый страж бы запретил (250 м – 3 км и без координат), в большинстве похожи на настоящие дубли. Откатывать их оптом не предлагаю, только по ручной сверке. Refs #2690, Refs #1772 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 2 commits 2026-09-17 07:50:11 +00:00
ФИАС-проход 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>
fix(tradein/dedup): дома с районом, приклеенным к номеру («17Ар-н Академический»), сливаются со своими двойниками (#1772)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 24s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m47s
e2b1a8adee
Канон-ключ 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>
Light1YT added 2 commits 2026-09-17 09:45:22 +00:00
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>
Слить свежий origin/main в fix/houses-dedup (вместо rebase: ветка уже опубликована, push без --force)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m21s
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 17s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
e3616e3611
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

Доработка запушена, голова 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/main a130303c, это стык #3554 и #3556. Схема в CI собралась, невалидных индексов нет.

🤖 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/main` `a130303c`, это стык #3554 и #3556. Схема в CI собралась, невалидных индексов нет. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Light1YT added 1 commit 2026-09-17 10:49:02 +00:00
Merge remote-tracking branch 'origin/main' into fix/houses-dedup
All checks were successful
CI Trade-In / changes (pull_request) Successful in 18s
CI / changes (pull_request) Successful in 24s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 8m37s
f9045673e1
bot-backend merged commit f9bebdf40f into main 2026-09-17 11:17:38 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3558
No description provided.