chore(tradein/db): снести три колонки-заглушки из витрины поиска (#2857) #2858

Merged
bot-backend merged 1 commit from chore/2857-drop-placeholder-columns into main 2026-08-13 07:25:44 +00:00
Collaborator

Summary

Витрина поиска listings_search_mv с миграции 050 отдавала четыре колонки, заданные литералом NULL прямо в определении. Три из них — distance_to_metro_m, last_price_change, photos_count — сняты миграцией 261. district не тронут: он объявлен в schemas/search_response.py, API его отдаёт, и его снос — ломающее изменение контракта (решение владельца, вынесено в #2857 отдельно).

Правок кода нет: у трёх колонок ноль читателей.

Читателей действительно ноль — перепроверено на origin/main

проверка результат
git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main 8 попаданий, все 8 — сами файлы 050/094 (объявление + комментарий над ним)
SELECT * из витрины где угодно в репо 0
SQLAlchemy-рефлексия витрины нет
единственный читатель services/search_query.py явный список из 27 имён, ни одной из трёх
зависимые объекты на проде (pg_depend/pg_rewrite) 0

Гранты (главная ловушка — C3, FDW-гранты после DROP ... CASCADE)

information_schema.role_table_grants по витрине пуст всегда и ничего не доказывает: information_schema не показывает материализованные представления в принципе. Смотреть надо pg_class.relacl.

Прод, до правки: relacl = {tradein=arwdDxt/tradein} — только владелец. Колоночных грантов нет, pg_default_acl пуст. Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history — на витрину ему не давали. Восстанавливать сегодня нечего.

Тем не менее ACL снимается и переигрывается в той же транзакции: между написанием файла и деплоем может пройти неделя, и ручной слепок протухнет молча. Первая редакция блока теряла колоночные гранты (pg_attribute.attacl живёт не в relacl) — поймано прогоном на одноразовой БД, исправлено.

Индексы

Все 6 воссоздаются один в один (050/094, сверено с прод-каталогом). UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него ночной REFRESH MATERIALIZED VIEW CONCURRENTLY (refresh_search_matview, 03:00-04:00 UTC) падает — и молчит до самой ночи. Отдельный тест краснеет, если индекс исчезнет из определения.

Цена пересоздания

Транзакция держит ACCESS EXCLUSIVE от DROP до COMMIT. Замер на проде: тело витрины — 6.6 s (EXPLAIN ANALYZE, прогретый кэш), плюс 6 индексов (GIN tsv 19 МБ + GIN trgm 17 МБ при maintenance_work_mem 64 МБ) → ориентир 30-60 s. Приемлемо: за всю жизнь БД (stats_reset пуст) витрина видела 225 seq_scan и 63 idx_scan, причём суточный CONCURRENTLY-рефреш сам даёт по seq_scan в день — то есть /api/v1/search к ней практически не ходит. Трюк «собрать под временным именем + переименовать» не нужен.

Если деплой попадёт в окно 03:00-04:00 UTC, DROP столкнётся с рефрешем → lock_timeout 5 s → честный красный деплой, миграция не помечается применённой, повторный деплой пройдёт.

Test plan

  • scripts/check-migration-lock-timeout.py✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 11)
  • Красный прогон. origin/main: 1 failed, 3 passedlistings_search_mv снова отдаёт колонки-заглушки ['distance_to_metro_m', 'last_price_change', 'photos_count']. Ветка: 4 passed. Тест смотрит на состав списка колонок, разобранного из актуального определения витрины, а не на подстроку: переформатирование SQL или NULL::intNULL::integer его не трогают.
  • Полный прогон миграции на одноразовой БД (postgis 16-3.4, без публикации портов): EXIT=0; колонок 34 → 31; district на месте (attnum 29); индексов 6; REFRESH MATERIALIZED VIEW CONCURRENTLY проходит; точный SELECT из search_query.py выполняется; повторный прогон того же файла — идемпотентен.
  • Ловушка грантов воспроизведена там же: выданы GRANT SELECT + GRANT SELECT (listing_id, address) WITH GRANT OPTION для gendesign_reader → после миграции relacl и оба attacl восстановлены байт-в-байт ({gendesign_reader=r*/tradein}).
  • Проверено, а не предположено: ALTER MATERIALIZED VIEW ... DROP COLUMN и ALTER TABLE <matview> DROP COLUMN оба отвечают ERROR: ALTER action DROP COLUMN cannot be performed ... This operation is not supported for materialized views — отсюда пересоздание.
  • test_migrations_manifest.py — 4 passed (файл дописан в _manifest_applied.txt).
  • После деплоя: строка 261_...sql в _schema_migrations; 31 колонка; pg_matviews.definition без distance_to_metro_m; relacl эквивалентен доприменительному; ночной refresh_search_matviewstatus='done'; ответ /api/v1/search по-прежнему содержит ключ district.

Критерий приёмки записан в шапке миграции, до применения.

Refs #2857

## Summary Витрина поиска `listings_search_mv` с миграции 050 отдавала четыре колонки, заданные литералом `NULL` прямо в определении. Три из них — `distance_to_metro_m`, `last_price_change`, `photos_count` — сняты миграцией **261**. `district` **не тронут**: он объявлен в `schemas/search_response.py`, API его отдаёт, и его снос — ломающее изменение контракта (решение владельца, вынесено в #2857 отдельно). Правок кода нет: у трёх колонок ноль читателей. ## Читателей действительно ноль — перепроверено на `origin/main` | проверка | результат | |---|---| | `git grep -E "distance_to_metro_m\|last_price_change\|photos_count" origin/main` | 8 попаданий, **все 8** — сами файлы `050`/`094` (объявление + комментарий над ним) | | `SELECT *` из витрины где угодно в репо | 0 | | SQLAlchemy-рефлексия витрины | нет | | единственный читатель `services/search_query.py` | явный список из 27 имён, ни одной из трёх | | зависимые объекты на проде (`pg_depend`/`pg_rewrite`) | 0 | ## Гранты (главная ловушка — C3, FDW-гранты после `DROP ... CASCADE`) `information_schema.role_table_grants` по витрине пуст **всегда** и ничего не доказывает: information_schema не показывает материализованные представления в принципе. Смотреть надо `pg_class.relacl`. **Прод, до правки:** `relacl = {tradein=arwdDxt/tradein}` — только владелец. Колоночных грантов нет, `pg_default_acl` пуст. Для сравнения: `gendesign_reader` имеет SELECT на `listings` и `offer_price_history` — на витрину ему не давали. Восстанавливать сегодня нечего. Тем не менее ACL снимается и переигрывается **в той же транзакции**: между написанием файла и деплоем может пройти неделя, и ручной слепок протухнет молча. Первая редакция блока теряла **колоночные** гранты (`pg_attribute.attacl` живёт не в `relacl`) — поймано прогоном на одноразовой БД, исправлено. ## Индексы Все 6 воссоздаются один в один (`050`/`094`, сверено с прод-каталогом). UNIQUE `listings_search_mv_id_idx (listing_id)` обязателен: без него ночной `REFRESH MATERIALIZED VIEW CONCURRENTLY` (`refresh_search_matview`, 03:00-04:00 UTC) падает — и молчит до самой ночи. Отдельный тест краснеет, если индекс исчезнет из определения. ## Цена пересоздания Транзакция держит ACCESS EXCLUSIVE от `DROP` до `COMMIT`. Замер на проде: тело витрины — 6.6 s (`EXPLAIN ANALYZE`, прогретый кэш), плюс 6 индексов (GIN tsv 19 МБ + GIN trgm 17 МБ при `maintenance_work_mem` 64 МБ) → ориентир **30-60 s**. Приемлемо: за всю жизнь БД (`stats_reset` пуст) витрина видела 225 seq_scan и 63 idx_scan, причём суточный CONCURRENTLY-рефреш сам даёт по seq_scan в день — то есть `/api/v1/search` к ней практически не ходит. Трюк «собрать под временным именем + переименовать» не нужен. Если деплой попадёт в окно 03:00-04:00 UTC, `DROP` столкнётся с рефрешем → `lock_timeout` 5 s → честный красный деплой, миграция не помечается применённой, повторный деплой пройдёт. ## Test plan - [x] `scripts/check-migration-lock-timeout.py` → `✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: 11)` - [x] **Красный прогон.** `origin/main`: `1 failed, 3 passed` — `listings_search_mv снова отдаёт колонки-заглушки ['distance_to_metro_m', 'last_price_change', 'photos_count']`. Ветка: `4 passed`. Тест смотрит на **состав списка колонок**, разобранного из актуального определения витрины, а не на подстроку: переформатирование SQL или `NULL::int` → `NULL::integer` его не трогают. - [x] **Полный прогон миграции на одноразовой БД** (postgis 16-3.4, без публикации портов): EXIT=0; колонок 34 → **31**; `district` на месте (attnum 29); индексов 6; `REFRESH MATERIALIZED VIEW CONCURRENTLY` проходит; точный SELECT из `search_query.py` выполняется; повторный прогон того же файла — идемпотентен. - [x] **Ловушка грантов воспроизведена там же:** выданы `GRANT SELECT` + `GRANT SELECT (listing_id, address) WITH GRANT OPTION` для `gendesign_reader` → после миграции `relacl` и оба `attacl` восстановлены байт-в-байт (`{gendesign_reader=r*/tradein}`). - [x] Проверено, а не предположено: `ALTER MATERIALIZED VIEW ... DROP COLUMN` и `ALTER TABLE <matview> DROP COLUMN` оба отвечают `ERROR: ALTER action DROP COLUMN cannot be performed ... This operation is not supported for materialized views` — отсюда пересоздание. - [x] `test_migrations_manifest.py` — 4 passed (файл дописан в `_manifest_applied.txt`). - [ ] После деплоя: строка `261_...sql` в `_schema_migrations`; 31 колонка; `pg_matviews.definition` без `distance_to_metro_m`; `relacl` эквивалентен доприменительному; ночной `refresh_search_matview` → `status='done'`; ответ `/api/v1/search` по-прежнему содержит ключ `district`. Критерий приёмки записан **в шапке миграции, до применения**. Refs #2857
bot-backend added 1 commit 2026-08-13 07:17:49 +00:00
chore(tradein/db): снести три колонки-заглушки из витрины поиска (#2857)
All checks were successful
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 Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m21s
fd328f75e0
listings_search_mv с 050 отдавала distance_to_metro_m, last_price_change и
photos_count литеральным NULL: имена зарезервировали, реализацию не подключили
никогда. Читателей ноль — `git grep` по origin/main даёт 8 попаданий, и все
восемь это сами файлы 050/094; SELECT * по витрине в репозитории нет ни одного,
рефлексии тоже, единственный читатель (services/search_query.py) перечисляет
27 колонок явно и ни одну из трёх не просит. Правок кода миграция не требует.

district оставлен намеренно: он объявлен в schemas/search_response.py, то есть
API его отдаёт, и снос — ломающее изменение контракта, решение владельца.

Материализованному представлению нельзя удалить колонку (проверено на
одноразовой БД: ALTER MATERIALIZED VIEW и ALTER TABLE одинаково отвечают «not
supported for materialized views»), поэтому пересоздание — как в 094. Без
CASCADE: зависимых объектов на проде ноль, а появись зависимость — деплой обязан
покраснеть, а не снести её молча.

Гранты снимаются и переигрываются в той же транзакции, а не переносятся руками:
DROP уносит ACL, и ручной слепок протухает молча, если файл пролежит до деплоя.
Сегодня переигрывать нечего (relacl витрины — только владелец), но первая
редакция блока теряла колоночные гранты — это поймал прогон на одноразовой БД,
не рассуждение, и снимок теперь берёт и pg_attribute.attacl.

Все 6 индексов воссоздаются, включая UNIQUE по listing_id — без него ночной
REFRESH ... CONCURRENTLY молчит до самой ночи, а потом падает.

Тест собирает список колонок разбором актуального определения витрины и
краснеет на составе списка, а не на подстроке: переформатирование SQL его не
трогает, возврат колонки — трогает. Отдельно сверяет, что API не просит у
витрины колонок, которых в ней нет.

Refs #2857
bot-backend merged commit 05d2126c5a into main 2026-08-13 07:25:44 +00:00
Author
Collaborator

Проверил утверждение, которое несёт всю проверку грантов — оно верно

Вы опровергли мою инструкцию по существу, и это стоит зафиксировать, потому что ошибка была бы тихой.

information_schema.role_table_grants по listings_search_mv →  0 строк
pg_class.relacl:
  listings_search_mv  m  {tradein=arwdDxt/tradein}
  listings            r  {tradein=arwdDxt/tradein, gendesign_reader=r/tradein}

Контраст решающий: у таблицы listings грант читателю есть и виден, у витрины его нет. Но если бы он был — information_schema всё равно показал бы ноль, потому что материализованные представления она не отображает в принципе. То есть ноль там верен случайно, а не потому, что грантов нет: тот самый четвёртый вид нуля, где не состоялся сам замер.

Проверять ACL матвьюх — только через pg_class.relacl плюс pg_attribute.attacl для колоночных.

Отдельно — про вашу собственную поправку

То, что первая редакция блока восстановления читала только relacl и молча теряла колоночные гранты, вы нашли не рассуждением, а прогоном: выдали GRANT SELECT (listing_id, address) ... WITH GRANT OPTION на одноразовой БД и посмотрели, что осталось после. Это ровно правильный ход — придумать состояние, при котором правка обязана сломаться, и проверить, ломается ли.

Блок восстановления оставлен, хотя восстанавливать сегодня нечего, — верно: ручной слепок протухает молча, а миграция едет на прод через неопределённое время.

Мои числа, которые вы поправили

  • «41 колонка со 100% NULL» — в самой витрине их 5, не 41: три снимаемых, district и cadastral_number. Последняя не заглушка, а живая колонка с писателем, которой площадки не отдают данных. 41 относится к другим объектам эпика.
  • «DROP MV теряет гранты — снимите и восстановите» — механизм верный, объект неподходящий: сторонних грантов нет вовсе.

Мой след, который вы заметили

260_houses_drop_has_panorama.sql внутри шапки называет себя 259_ — это моё переименование вчера при коллизии номеров: два независимых PR взяли 259, я переименовал второй в 260 вместе со ссылкой в тесте и записью в манифесте, а комментарий в шапке пропустил. Правильно, что не тронули чужой применённый файл; поправлю отдельно.

Смержено: CI зелёный 8/8, running — 0 строк, процессов сбора 0.

## Проверил утверждение, которое несёт всю проверку грантов — оно верно Вы опровергли мою инструкцию по существу, и это стоит зафиксировать, потому что ошибка была бы тихой. ``` information_schema.role_table_grants по listings_search_mv → 0 строк pg_class.relacl: listings_search_mv m {tradein=arwdDxt/tradein} listings r {tradein=arwdDxt/tradein, gendesign_reader=r/tradein} ``` Контраст решающий: у таблицы `listings` грант читателю **есть** и виден, у витрины его нет. Но если бы он был — `information_schema` всё равно показал бы ноль, потому что материализованные представления она не отображает **в принципе**. То есть ноль там верен **случайно**, а не потому, что грантов нет: тот самый четвёртый вид нуля, где не состоялся сам замер. Проверять ACL матвьюх — только через `pg_class.relacl` плюс `pg_attribute.attacl` для колоночных. ## Отдельно — про вашу собственную поправку То, что первая редакция блока восстановления читала только `relacl` и **молча теряла колоночные гранты**, вы нашли не рассуждением, а прогоном: выдали `GRANT SELECT (listing_id, address) ... WITH GRANT OPTION` на одноразовой БД и посмотрели, что осталось после. Это ровно правильный ход — придумать состояние, при котором правка обязана сломаться, и проверить, ломается ли. Блок восстановления оставлен, хотя восстанавливать сегодня нечего, — верно: ручной слепок протухает молча, а миграция едет на прод через неопределённое время. ## Мои числа, которые вы поправили - **«41 колонка со 100% NULL»** — в самой витрине их 5, не 41: три снимаемых, `district` и `cadastral_number`. Последняя не заглушка, а живая колонка с писателем, которой площадки не отдают данных. 41 относится к другим объектам эпика. - **«DROP MV теряет гранты — снимите и восстановите»** — механизм верный, объект неподходящий: сторонних грантов нет вовсе. ## Мой след, который вы заметили `260_houses_drop_has_panorama.sql` внутри шапки называет себя `259_` — это моё переименование вчера при коллизии номеров: два независимых PR взяли 259, я переименовал второй в 260 вместе со ссылкой в тесте и записью в манифесте, а комментарий в шапке пропустил. Правильно, что не тронули чужой применённый файл; поправлю отдельно. Смержено: CI зелёный 8/8, `running` — 0 строк, процессов сбора 0.
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#2858
No description provided.