feat(tradein/houses): журнал слияний домов — слияние стало обратимым #2740

Merged
bot-backend merged 1 commit from feat/2690-merge-audit-log into main 2026-08-06 15:41:21 +00:00
Collaborator

Зачем

house_dedup_merge удалял проигравшие дома вместе с дочерними строками и не оставлял восстановимого следа. Единственной записью «кто в кого» была строка logger.info, а логи контейнера ротируются быстрее суток.

Это не гипотетика. Расписание на проде enabled=true, dry_run=false, такт 7 дней. Шесть прогонов с 27.06 уже удалили 119 строк houses (losers_deleted в scrape_runs: 2 / 39 / 31 / 9 / 6 / 32). Следующий — 08.08 04:15 UTC; замер тем же выражением, что и код, даёт 93 проигравших.

#2690 требует журнал как предусловие любой дальнейшей работы по схлопыванию.

Что записывается и почему именно это

Поле Зачем
loser_row jsonb Полный снимок удаляемой строки. Ссылка на удалённую строку бесполезна — храним содержимое
keeper_before jsonb Победитель ДО переноса метаданных. Без него откат вернёт дом, но оставит его ФИАС на победителе — и следующий fias-проход сольёт их обратно
children_repointed {"таблица.колонка": [id]}. Строки целы, сменилась только ссылка → id, а не содержимое (иначе одни listings дали бы ~7 КБ/строку)
children_deleted {"таблица": [{строка}]}. Уничтожены коллизией UNIQUE — id недостаточно, нужен снимок
merge_pass / cluster_key / geo_guard / distance_m Основание слияния. Ровно этих полей не хватило в #2690, чтобы ответить «сколько слияний прошло на расстояниях, которые страж заблокировал бы» по данным, а не по ревью
batch_id / run_id / initiator / merged_at Единица отката, кто и когда

Без FK на houses/scrape_runs: журнал обязан переживать строки, которые описывает.

Атомарность

Журнал пишется в той же транзакции, что и слияние — разрыв «слияние прошло, запись не легла» невозможен по построению, dry_run откатывает оба.

Обратная операция

BEGIN;
SELECT * FROM house_merge_undo('<batch_id>');  -- прочитать статусы
COMMIT;  -- или ROLLBACK

Построчный отчёт, включая то, что вернуть не удалось. Проверено на живом Postgres end-to-end.

Объём

~5.3 КБ на запись (2.5 + 2.5 КБ снимки + списки id). Наблюдаемый темп 20 слияний/нед → ~5.5 МБ/год. Потолок при схлопывании всех 9 571 домов — ~50 МБ против 23 МБ самой houses. Retention не нужен.

Нейтральность

Ключ схлопывания, правило выбора победителя и гео-страж не тронуты — это отдельные спорные задачи. distance_m пишется и на fias-проходе, где страж выключен: измерение не есть проверка, но раньше не было и измерения.

Сторож полноты

Если у houses появится FK-ребёнок, которого не знает _STEPS, слияние падает вместо молчаливого уничтожения его строк по CASCADE (9 из 11 FK — ON DELETE CASCADE). На пропущенном ребёнке сломалась миграция 133 — там отказ был громким, здесь был бы тихим.

Побочно: 4 живых теста этого модуля были мертвы

Они самоотключаются без Postgres, поэтому в CI не запускались ни разу и молча разошлись со схемой: houses.url стал NOT NULL, а у пары в первом тесте проигравший без geom — канон-страж #2187 справедливо отказывается такое сливать. Починены; фикстуры, не утверждения.

Test plan

  • 43 passed в test_house_dedup_merge.py, из них 6 на реальном Postgres (16-3.4, схема из всех 218 миграций)
  • Полный бэкенд-сьют: 3855 passed, 10 skipped
  • Миграция 230 применяется дважды подряд (идемпотентна) и на девственной БД в своей очереди
  • Round-trip: слияние → журнал → house_merge_undo → сравнение to_jsonb по всем колонкам всех 9 засеянных строк, а не по паре
  • pre-commit зелёный
  • Прод: миграция применилась, таблица и функция есть. Слияний не запускать

Refs #2690

## Зачем `house_dedup_merge` удалял проигравшие дома вместе с дочерними строками и не оставлял восстановимого следа. Единственной записью «кто в кого» была строка `logger.info`, а логи контейнера ротируются быстрее суток. **Это не гипотетика.** Расписание на проде `enabled=true`, `dry_run=false`, такт 7 дней. Шесть прогонов с 27.06 уже удалили **119 строк** `houses` (`losers_deleted` в `scrape_runs`: 2 / 39 / 31 / 9 / 6 / 32). Следующий — **08.08 04:15 UTC**; замер тем же выражением, что и код, даёт **93 проигравших**. #2690 требует журнал как **предусловие** любой дальнейшей работы по схлопыванию. ## Что записывается и почему именно это | Поле | Зачем | |---|---| | `loser_row` jsonb | Полный снимок удаляемой строки. Ссылка на удалённую строку бесполезна — храним содержимое | | `keeper_before` jsonb | Победитель ДО переноса метаданных. Без него откат вернёт дом, но оставит его ФИАС на победителе — и следующий fias-проход сольёт их обратно | | `children_repointed` | `{"таблица.колонка": [id]}`. Строки целы, сменилась только ссылка → id, а не содержимое (иначе одни listings дали бы ~7 КБ/строку) | | `children_deleted` | `{"таблица": [{строка}]}`. Уничтожены коллизией UNIQUE — id недостаточно, нужен снимок | | `merge_pass` / `cluster_key` / `geo_guard` / `distance_m` | **Основание** слияния. Ровно этих полей не хватило в #2690, чтобы ответить «сколько слияний прошло на расстояниях, которые страж заблокировал бы» по данным, а не по ревью | | `batch_id` / `run_id` / `initiator` / `merged_at` | Единица отката, кто и когда | Без FK на `houses`/`scrape_runs`: журнал обязан переживать строки, которые описывает. ## Атомарность Журнал пишется в **той же транзакции**, что и слияние — разрыв «слияние прошло, запись не легла» невозможен по построению, `dry_run` откатывает оба. ## Обратная операция ```sql BEGIN; SELECT * FROM house_merge_undo('<batch_id>'); -- прочитать статусы COMMIT; -- или ROLLBACK ``` Построчный отчёт, включая то, что вернуть **не** удалось. Проверено на живом Postgres end-to-end. ## Объём ~5.3 КБ на запись (2.5 + 2.5 КБ снимки + списки id). Наблюдаемый темп 20 слияний/нед → **~5.5 МБ/год**. Потолок при схлопывании всех 9 571 домов — ~50 МБ против 23 МБ самой `houses`. **Retention не нужен.** ## Нейтральность Ключ схлопывания, правило выбора победителя и гео-страж **не тронуты** — это отдельные спорные задачи. `distance_m` пишется и на fias-проходе, где страж выключен: измерение не есть проверка, но раньше не было и измерения. ## Сторож полноты Если у `houses` появится FK-ребёнок, которого не знает `_STEPS`, слияние **падает** вместо молчаливого уничтожения его строк по CASCADE (9 из 11 FK — `ON DELETE CASCADE`). На пропущенном ребёнке сломалась миграция 133 — там отказ был громким, здесь был бы тихим. ## Побочно: 4 живых теста этого модуля были мертвы Они самоотключаются без Postgres, поэтому **в CI не запускались ни разу** и молча разошлись со схемой: `houses.url` стал NOT NULL, а у пары в первом тесте проигравший без `geom` — канон-страж #2187 справедливо отказывается такое сливать. Починены; фикстуры, не утверждения. ## Test plan - [x] `43 passed` в `test_house_dedup_merge.py`, из них **6 на реальном Postgres** (16-3.4, схема из всех 218 миграций) - [x] Полный бэкенд-сьют: `3855 passed, 10 skipped` - [x] Миграция 230 применяется дважды подряд (идемпотентна) и на девственной БД в своей очереди - [x] Round-trip: слияние → журнал → `house_merge_undo` → сравнение `to_jsonb` **по всем колонкам** всех 9 засеянных строк, а не по паре - [x] pre-commit зелёный - [ ] Прод: миграция применилась, таблица и функция есть. **Слияний не запускать** Refs #2690
bot-backend added 1 commit 2026-08-06 15:21:59 +00:00
feat(tradein/houses): журнал слияний домов — слияние стало обратимым
All checks were successful
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 / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m11s
5ac310bb80
Проход house_dedup_merge удалял проигравшие дома вместе с дочерними строками,
не оставляя восстановимого следа: единственной записью «кто в кого» была строка
logger.info, а логи контейнера ротируются быстрее суток. Через сутки после
прогона нельзя было даже назвать пары, не говоря о возврате.

Это не гипотетическая проблема: расписание на проде enabled=true, dry_run=false,
такт 7 дней; шесть прогонов с 27.06 уже удалили 119 строк houses (2/39/31/9/6/32
по счётчикам scrape_runs), ближайший — 08.08.

Миграция 230: таблица house_merge_log (одна строка = один проигравший) с полным
jsonb-снимком удалённой строки, снимком победителя ДО переноса метаданных,
списком id перенесённых дочерних строк, полными снимками дочерних строк,
уничтоженных коллизией UNIQUE, и основанием слияния — проход, значение ключа,
был ли включён гео-страж и расстояние между домами. Плюс функция
house_merge_undo(batch_id) — обратная операция с построчным отчётом, включая
то, что вернуть не удалось.

Журнал пишется в ТОЙ ЖЕ транзакции, что и слияние, поэтому разрыв «слияние
прошло, запись не легла» невозможен по построению, а dry_run откатывает оба.

Правка НЕЙТРАЛЬНА к спорным решениям прохода: ключ схлопывания, правило выбора
победителя и гео-страж не тронуты. Расстояние теперь ПИШЕТСЯ и на fias-проходе,
где страж выключен, — измерение не есть проверка, но раньше не было и его.

Сторож полноты: если у houses появится FK-ребёнок, которого не знает _STEPS,
слияние падает вместо того, чтобы молча уничтожить его строки по CASCADE
(9 из 11 FK — ON DELETE CASCADE; ровно на пропущенном ребёнке сломалась
миграция 133, но там отказ был громким).

Объём: ~5.3 КБ на запись, наблюдаемый темп 20 слияний/нед → ~5.5 МБ/год,
потолок при схлопывании всех 9 571 домов ~50 МБ. Retention не нужен.

Заодно починены 4 живых теста этого модуля: они самоотключаются без Postgres,
поэтому в CI никогда не запускались и молча разошлись со схемой (houses.url
стал NOT NULL; у пары в первом тесте проигравший без geom, что канон-страж
#2187 справедливо отказывается сливать). Проверено на живом Postgres:
43 passed, из них 6 — на реальной БД.

Refs #2690
bot-backend merged commit c86a5378ef into main 2026-08-06 15:41:21 +00:00
bot-backend deleted branch feat/2690-merge-audit-log 2026-08-06 15:41:21 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2740
No description provided.