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
3 changed files with 416 additions and 0 deletions

View file

@ -0,0 +1,270 @@
-- 261_listings_search_mv_drop_placeholder_columns.sql
-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv:
-- distance_to_metro_m, last_price_change, photos_count.
--
-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов),
-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст
-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 —
-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql
-- (суточный REFRESH ... CONCURRENTLY).
-- Apply after: 260_houses_drop_has_panorama.sql
-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому
-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен.
--
-- ── ЧТО ЗА НОЛЬ ────────────────────────────────────────────────────────────
-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении
-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ:
-- имена зарезервировали в 050, реализацию не подключили никогда.
--
-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк):
-- null_frac = 1.0 у 5 колонок: cadastral_number, district,
-- distance_to_metro_m, last_price_change, photos_count.
-- Сносим три. После применения колонок с null_frac = 1.0 останется 2
-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают,
-- см. 216/search_query.py; district — вынесен решением владельца, ниже).
--
-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти:
-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main`
-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним).
-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки
-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории
-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный
-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет.
--
-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет
-- services/search_query.py:138 и объявляет schemas/search_response.py:44
-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее
-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857.
-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`).
--
-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ───────────────────────────────────────
-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW
-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает.
-- Единственный путь — пересоздание, как в 094.
--
-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite
-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и
-- деплой честно покраснеет; CASCADE снёс бы её молча.
--
-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ─────────────
-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после
-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную).
-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не
-- предположено:
-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного
-- стороннего grantee; column-level грантов нет; pg_default_acl пуст.
-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не
-- доказывает: information_schema не показывает материализованные представления
-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко
-- проглядеть.)
-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history —
-- на витрину ему не давали.
-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием
-- файла и его применением на проде может пройти неделя, и ручной слепок к тому
-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому
-- врать не может.
--
-- ── ИНДЕКСЫ ────────────────────────────────────────────────────────────────
-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один).
-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный
-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py,
-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с
-- «cannot refresh materialized view concurrently ... no unique index».
--
-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ─────────────────────────────────────────
-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели
-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026):
-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm
-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s
-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в
-- 9-17 s, но он делает вдвое больше работы (строит + сливает).
-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset
-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и
-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день.
-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным
-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен.
--
-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под
-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в
-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой
-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY →
-- честный красный деплой через 5 s, миграция не помечается применённой, повторный
-- деплой пройдёт.
--
-- IDEMPOTENCY / SAFETY:
-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому
-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо
-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094).
-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources.
-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же
-- способом. Восстанавливать нечего — значений не существовало.
--
-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения):
-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в
-- _schema_migrations (а не «деплой зелёный»).
-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change /
-- photos_count отсутствуют; district на месте, тип text.
-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'.
-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx.
-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец
-- и никого больше).
-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк.
-- 7. Следующий ночной refresh_search_matview завершается status='done'
-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс).
-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит
-- удалённых — их там и не было).
BEGIN;
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке.
SET LOCAL lock_timeout = '5s';
-- ── 1. Снимок ACL ДО сноса ─────────────────────────────────────────────────
-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст.
-- Владельца исключаем: CREATE вернёт его права сам.
-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут
-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном
-- на одноразовой БД, а не рассуждением.
CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS
SELECT
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee,
a.privilege_type,
a.is_grantable,
NULL::text AS column_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
CROSS JOIN LATERAL aclexplode(c.relacl) AS a
WHERE n.nspname = 'public'
AND c.relname = 'listings_search_mv'
AND c.relkind = 'm'
AND a.grantee <> c.relowner
UNION ALL
SELECT
CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END,
a.privilege_type,
a.is_grantable,
quote_ident(att.attname)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped
CROSS JOIN LATERAL aclexplode(att.attacl) AS a
WHERE n.nspname = 'public'
AND c.relname = 'listings_search_mv'
AND c.relkind = 'm'
AND a.grantee <> c.relowner;
-- ── 2. Пересоздание витрины без трёх заглушек ──────────────────────────────
DROP MATERIALIZED VIEW IF EXISTS listings_search_mv;
CREATE MATERIALIZED VIEW listings_search_mv AS
SELECT
l.id AS listing_id,
l.source,
l.source_url,
l.address,
l.geom,
l.lat,
l.lon AS lng,
l.rooms,
l.area_m2 AS total_area,
l.floor,
l.total_floors,
l.price_rub,
l.price_per_m2,
l.cadastral_number,
l.is_active,
l.scraped_at,
-- House denorm
h.id AS house_id,
h.year_built,
h.house_class,
h.developer_name,
h.rating AS house_rating,
h.reviews_count AS house_ratings_count,
-- Cross-source aggregates
(SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count,
(SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources,
(SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito,
(SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian,
(SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex,
-- Price percentile within house
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2)
FROM listings ll
WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2,
-- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API
-- (schemas/search_response.py), снос — ломающее изменение контракта, решение
-- владельца (#2857). Соседние distance_to_metro_m / last_price_change /
-- photos_count сняты здесь: у них не было ни одного читателя.
NULL::text AS district,
-- Trigram-ready columns
l.address AS address_trgm,
-- Aggregated tsv (description + address + developer_name)
to_tsvector('russian',
coalesce(l.description, '') || ' ' ||
coalesce(l.address, '') || ' ' ||
coalesce(h.developer_name, '')
) AS tsv
FROM listings l
LEFT JOIN houses h ON h.id = l.house_id_fk
WHERE l.is_active = true
AND COALESCE(l.canonical, true) = true;
-- ── 3. Те же 6 индексов (050/094) ──────────────────────────────────────────
-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку.
CREATE UNIQUE INDEX listings_search_mv_id_idx
ON listings_search_mv (listing_id);
CREATE INDEX listings_search_mv_geom_idx
ON listings_search_mv USING GIST (geom);
CREATE INDEX listings_search_mv_filters_idx
ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC);
CREATE INDEX listings_search_mv_address_trgm_idx
ON listings_search_mv USING GIN (address_trgm gin_trgm_ops);
CREATE INDEX listings_search_mv_tsv_idx
ON listings_search_mv USING GIN (tsv);
CREATE INDEX listings_search_mv_sources_idx
ON listings_search_mv (has_avito, has_cian, has_yandex);
-- ── 4. Возврат грантов, снятых в п.1 ───────────────────────────────────────
-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит
-- из системного каталога, поэтому подставляется как есть.
-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT
-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя,
-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку.
DO $$
DECLARE
r record;
BEGIN
FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP
EXECUTE format(
'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s',
r.privilege_type,
CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END,
r.grantee,
CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END
);
RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %',
r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee;
END LOOP;
END
$$;
-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ────────────
-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий
-- приёмки по pg_stats нечем проверить.
ANALYZE listings_search_mv;
COMMENT ON MATERIALIZED VIEW listings_search_mv IS
'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки '
'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в '
'определении, ноль читателей во всём репозитории. district оставлен намеренно: '
'он объявлен в schemas/search_response.py, его снос — ломающее изменение '
'контракта API и решение владельца. Единственный читатель витрины — '
'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён '
'по той же причине, что и по market.v_houses.';
COMMIT;

View file

@ -249,3 +249,4 @@
258_houses_imv_transient_attempts.sql
259_data_quality_drop_pct_cadastr.sql
260_houses_drop_has_panorama.sql
261_listings_search_mv_drop_placeholder_columns.sql

View file

@ -0,0 +1,145 @@
"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674).
`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо
в определении: district, distance_to_metro_m, last_price_change, photos_count.
Это не потеря данных и не оборванный писатель имена зарезервировали, реализацию
не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты)
и они сняты миграцией 261; district оставлен намеренно он объявлен в
schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее
изменение контракта (решение владельца, вынесено отдельно в #2857).
Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её
актуального определения (самый старший NN среди файлов, создающих витрину) и
смотрит на состав списка. Переформатирование SQL, перестановка строк или смена
`NULL::int` на `NULL::integer` тест не трогают; возврат колонки краснит.
Без БД и сети: миграции читаются как текст, разбираются в структуру.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
MV = "listings_search_mv"
# Сняты 261: ноль читателей во всём репозитории на момент сноса.
DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count")
def _strip_sql_comments(sql: str) -> str:
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
return re.sub(r"--[^\n]*", "", sql)
def _latest_definition() -> str:
"""Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид."""
creators = [
p
for p in SQL_DIR.glob("*.sql")
if re.search(
rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b",
_strip_sql_comments(p.read_text("utf-8")),
re.I,
)
]
assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда"
return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8")
def mv_columns() -> list[str]:
"""Имена колонок витрины в порядке объявления.
Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым
той же глубины, имя колонки последний идентификатор элемента (алиас после
`AS` либо хвост `l.foo`).
"""
sql = _strip_sql_comments(_latest_definition())
body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1]
depth, items, cur = 0, [], []
for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I):
t = token.group(0)
if t == "(":
depth += 1
elif t == ")":
depth -= 1
elif depth == 0 and t == ",":
items.append("".join(cur))
cur = []
continue
elif depth == 0 and t.upper() == "FROM":
break
cur.append(t)
items.append("".join(cur))
return [item.split()[-1].split(".")[-1] for item in items if item.split()]
def test_placeholder_columns_are_gone_from_the_matview() -> None:
"""Red => витрина снова обещает поля, которых не заполняет (#2857).
Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел
после возврата колонки сначала заведи писателя, потом колонку, а не наоборот.
"""
cols = mv_columns()
still_there = [c for c in DROPPED if c in cols]
assert not still_there, (
f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя "
"читается снаружи как «данные есть, просто у этого объекта пусто» — это "
"хуже мёртвого кода, потому что видно в контракте."
)
def test_district_is_deliberately_kept() -> None:
"""Red => district снесли заодно, а он в схеме ответа API.
schemas/search_response.py объявляет `district: str | None`, services/search_query.py
его тянет снос ломает контракт /api/v1/search. Это решение владельца (#2857),
а не побочный эффект уборки соседних заглушек. Убирать вместе со схемой ответа.
"""
assert "district" in mv_columns(), (
f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: "
"ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой."
)
def test_search_api_selects_only_columns_the_matview_has() -> None:
"""Настоящий инвариант: то, что просит API, витрина обязана иметь.
Именно эта проверка отличает «список колонок» от «поиска подстроки»: она
краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх
известных именах.
"""
from app.schemas.search import SearchParams
from app.services.search_query import build_search_query
sql, _ = build_search_query(SearchParams())
selected = [
c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip()
]
missing = [c for c in selected if c not in mv_columns()]
assert not missing, (
f"services/search_query.py просит у {MV} колонки, которых в её определении нет: "
f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из "
"schemas/search_response.py."
)
def test_unique_index_for_concurrent_refresh_survives_recreation() -> None:
"""Red => ночной REFRESH ... CONCURRENTLY упадёт.
app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание
refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает
«cannot refresh materialized view concurrently ... no unique index» а витрина,
которую пересоздали и забыли проиндексировать, молчит до самой ночи.
"""
sql = _strip_sql_comments(_latest_definition())
assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), (
f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — "
"REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен."
)