chore(tradein/db): уборка временных таблиц, дублей индексов и звёздочки в v_data_quality (#2746)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 2m3s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m2s
Deploy Trade-In / build-backend (push) Successful in 30s
Deploy Trade-In / deploy (push) Successful in 2m3s
Миграции 222 (DROP 2 tmp-таблиц + 5 строгих дублей индексов + v_data_quality с явным списком колонок) и 225 (CREATE INDEX CONCURRENTLY под FK listing_source_snapshots.run_id). Проверено на прод-БД в BEGIN…ROLLBACK и на чистой схеме (полный bootstrap 225 миграций в одноразовом контейнере).
This commit is contained in:
parent
2496670859
commit
0535fa209a
3 changed files with 191 additions and 0 deletions
140
tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql
Normal file
140
tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
-- 222_db_audit_cleanup.sql
|
||||
-- Уборка по итогам ручного аудита схемы tradein (2026-08-06). Три независимых
|
||||
-- части, порядок между ними не важен (разные объекты, нет пересекающихся
|
||||
-- зависимостей). Идемпотентно целиком: IF EXISTS везде, CREATE OR REPLACE VIEW.
|
||||
--
|
||||
-- ── A. Временные таблицы разовой чистки 02.07 ─────────────────────────────────
|
||||
-- tmp_purged_junk_houses_0702 / tmp_purged_junk_links_0702 — снэпшоты записей,
|
||||
-- вычищенных вручную 2026-07-02. Проверено на проде перед этой миграцией:
|
||||
-- * обе существуют под этими именами, суммарно 2.9 МБ
|
||||
-- (tmp_purged_junk_houses_0702 = 1608 kB, tmp_purged_junk_links_0702 = 1272 kB);
|
||||
-- * pg_constraint: ни один FK НЕ ссылается на них (confrelid пусто);
|
||||
-- * pg_depend: ни view, ни другой объект их не использует (только сами по себе).
|
||||
-- Дальше не нужны — были just-in-case снэпшотом на случай отката чистки, месяц
|
||||
-- прошёл без претензий.
|
||||
--
|
||||
-- ── B. Пять строгих дублей индексов ───────────────────────────────────────────
|
||||
-- Строгий дубль = тот же access method + тот же УПОРЯДОЧЕННЫЙ список колонок
|
||||
-- (включая ASC/DESC/NULLS) + тот же частичный предикат (или оба NULL) + тот же
|
||||
-- opclass, независимо от UNIQUE-флага и имени. Проверено запросом по
|
||||
-- pg_index/pg_stat_user_indexes/pg_opclass на проде — найдено РОВНО 5 пар (не 6,
|
||||
-- см. примечание ниже), в каждой паре оставляем индекс, несущий UNIQUE-constraint
|
||||
-- (дропнуть его нельзя без дропа constraint'а), дропаем чистый btree-дубль:
|
||||
--
|
||||
-- agents: DROP agents_source_ext_idx
|
||||
-- (дубль agents_ext_source_ext_agent_id_key, btree (ext_source, ext_agent_id);
|
||||
-- 2 скана за всё время — планировщик и так предпочитал unique-версию)
|
||||
-- ekb_geoportal_buildings: DROP ix_ekb_geoportal_buildings_street_house
|
||||
-- (дубль ekb_geoportal_buildings_street_norm_house_norm_key,
|
||||
-- btree (street_norm, house_norm); 13 945 сканов, но unique-версия того же
|
||||
-- определения покрывает те же запросы — 70 702 скана на ней)
|
||||
-- house_placement_history: DROP hph_source_item_idx
|
||||
-- (дубль house_placement_history_source_ext_item_id_key,
|
||||
-- btree (source, ext_item_id); 0 сканов — полностью мёртв)
|
||||
-- house_reviews: DROP hr_source_ext_idx
|
||||
-- (дубль house_reviews_source_ext_review_id_key, btree (source, ext_review_id);
|
||||
-- 15 сканов)
|
||||
-- sellers: DROP sellers_source_idx
|
||||
-- (дубль sellers_source_ext_seller_id_key, btree (source, ext_seller_id);
|
||||
-- 2 скана)
|
||||
--
|
||||
-- ПРИМЕЧАНИЕ (расхождение с ожиданием «шесть»): при систематической проверке
|
||||
-- (3 независимых метода: нормализованный DDL-текст, сравнение indkey/indoption,
|
||||
-- сравнение opclass) строгих дублей найдено 5, не 6. Два похожих кандидата
|
||||
-- ЦЕЛЕНАПРАВЛЕННО исключены — их «дубль» только по списку колонок, а порядок
|
||||
-- сортировки различается (ASC,ASC у unique-версии против ASC,DESC у второй),
|
||||
-- то есть это ТОТ ЖЕ класс исключения, что explicitly подтверждённые
|
||||
-- idx_lss_source_date/listings_snapshots_listing_date_idx (#2607, см. миграцию 225):
|
||||
-- * offer_price_history: oph_listing_time_idx (listing_id ASC, change_time DESC)
|
||||
-- против offer_price_history_listing_change_uq (listing_id ASC, change_time ASC)
|
||||
-- * houses_price_dynamics: hpd_house_dim_idx (..., month_date DESC)
|
||||
-- против houses_price_dynamics_dim_key (..., month_date ASC)
|
||||
-- Оба НЕ тронуты. Если «шесть» подразумевали один из них — нужно явное
|
||||
-- подтверждение, что смешанный порядок сортировки в конкретном запросе не
|
||||
-- используется (тем же способом, каким для idx_lss_source_date подтверждено
|
||||
-- обратное).
|
||||
--
|
||||
-- ── C. v_data_quality — явный список колонок вместо SELECT * ─────────────────
|
||||
-- Сейчас: `WITH active_listings AS (SELECT * FROM listings WHERE is_active = true)`.
|
||||
-- Postgres разворачивает `*` в CREATE VIEW time в полный список колонок listings
|
||||
-- (89 на момент миграции) и фиксирует pg_depend на КАЖДУЮ из них — это то самое
|
||||
-- уже задокументированное в 214/216 предупреждение («View-зависимость: v_data_quality
|
||||
-- содержит SELECT * FROM listings, что фиксирует column-level зависимость на ВСЕ
|
||||
-- колонки»), которое обязывало делать DROP VIEW → DROP COLUMN → CREATE VIEW при
|
||||
-- каждой чистке listings.
|
||||
-- Фактически используются только 6 колонок active_listings ниже по телу view:
|
||||
-- id — IN (SELECT active_listings.id FROM active_listings)
|
||||
-- lat — pct_geocoded
|
||||
-- cadastral_number — pct_cadastr
|
||||
-- description — pct_description
|
||||
-- house_id_fk — JOIN houses h ON h.id = l.house_id_fk (pct_year_built)
|
||||
-- is_active — WHERE-фильтр самого CTE (создаёт зависимость даже не будучи
|
||||
-- в SELECT-списке, поэтому перечислен явно для наглядности)
|
||||
-- Поведение view НЕ меняется — только явный список вместо *. Тело SELECT (16
|
||||
-- выходных колонок) скопировано без изменений из 216_dead_code_sweep.sql.
|
||||
-- COMMENT ON VIEW сохраняется автоматически (CREATE OR REPLACE VIEW не сбрасывает
|
||||
-- комментарий).
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (listings), 214_drop_dead_run_metrics.sql,
|
||||
-- 216_dead_code_sweep.sql (последний DDL v_data_quality).
|
||||
-- Идемпотентно: DROP TABLE IF EXISTS / DROP INDEX IF EXISTS / CREATE OR REPLACE VIEW.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── A ──────────────────────────────────────────────────────────────────────
|
||||
DROP TABLE IF EXISTS tmp_purged_junk_houses_0702, tmp_purged_junk_links_0702;
|
||||
|
||||
-- ── B ──────────────────────────────────────────────────────────────────────
|
||||
DROP INDEX IF EXISTS agents_source_ext_idx;
|
||||
DROP INDEX IF EXISTS ix_ekb_geoportal_buildings_street_house;
|
||||
DROP INDEX IF EXISTS hph_source_item_idx;
|
||||
DROP INDEX IF EXISTS hr_source_ext_idx;
|
||||
DROP INDEX IF EXISTS sellers_source_idx;
|
||||
|
||||
-- ── C ──────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE VIEW v_data_quality AS
|
||||
WITH active_listings AS (
|
||||
SELECT id, lat, cadastral_number, description, house_id_fk, is_active
|
||||
FROM listings
|
||||
WHERE is_active = true
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM houses) AS houses_total,
|
||||
(SELECT count(*) FROM houses h
|
||||
WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
|
||||
(SELECT count(*) FROM houses h
|
||||
WHERE EXISTS (SELECT 1 FROM house_sources hs
|
||||
WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
|
||||
(SELECT count(*) FROM houses h
|
||||
WHERE EXISTS (SELECT 1 FROM house_sources hs
|
||||
WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
|
||||
(SELECT count(*) FROM houses h
|
||||
WHERE EXISTS (SELECT 1 FROM house_sources hs
|
||||
WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
|
||||
(SELECT count(*) FROM (
|
||||
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
|
||||
) sub) AS houses_2plus_sources,
|
||||
(SELECT count(*) FROM (
|
||||
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
|
||||
) sub) AS houses_3plus_sources,
|
||||
(SELECT count(*) FROM active_listings) AS listings_active,
|
||||
(SELECT count(*) FROM (
|
||||
SELECT listing_id FROM listing_sources
|
||||
WHERE listing_id IN (SELECT id FROM active_listings)
|
||||
GROUP BY listing_id HAVING count(*) >= 2
|
||||
) sub) AS listings_dedup_2sources,
|
||||
(SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
|
||||
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
|
||||
(SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0
|
||||
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr,
|
||||
(SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
|
||||
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
|
||||
(SELECT count(*) FROM active_listings l
|
||||
JOIN houses h ON h.id = l.house_id_fk
|
||||
WHERE h.year_built IS NOT NULL) * 100.0
|
||||
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
|
||||
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
|
||||
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
|
||||
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
-- 225_listing_source_snapshots_run_id_idx.sql
|
||||
-- Индекс под внешний ключ listing_source_snapshots.run_id → scrape_runs(id)
|
||||
-- ON DELETE SET NULL. Индекса на run_id нет (проверено \d listing_source_snapshots
|
||||
-- на проде): есть только listing_source_snapshots_pkey (listing_source_id,
|
||||
-- snapshot_date), idx_lss_source_date (listing_source_id, snapshot_date DESC),
|
||||
-- idx_lss_snapshot_date (snapshot_date DESC) — ни один не начинается с run_id.
|
||||
-- Таблица — 2 872 080 строк (reltuples), 696 MB (pg_total_relation_size).
|
||||
-- Без индекса каждый DELETE из scrape_runs делает Seq Scan по 2.87М строк, чтобы
|
||||
-- обнулить run_id у зависимых снэпшотов (ON DELETE SET NULL).
|
||||
--
|
||||
-- ── Почему CONCURRENTLY и почему в файле нет BEGIN/COMMIT ────────────────────
|
||||
-- CREATE INDEX CONCURRENTLY не может выполняться внутри блока транзакции
|
||||
-- (Postgres: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block").
|
||||
-- На 2.87М строк / 696 MB обычный CREATE INDEX держит ACCESS EXCLUSIVE lock на
|
||||
-- время сборки (секунды-десятки секунд под нагрузкой) — на боевой таблице,
|
||||
-- которую пишет активный скрейпинг, это неприемлемо; нужен CONCURRENTLY.
|
||||
--
|
||||
-- Более ранние миграции с похожей потребностью (117, 120, 134, 137) сознательно
|
||||
-- ОТКАЗАЛИСЬ от CONCURRENTLY с комментарием «deploy migration runner wraps each
|
||||
-- file in an explicit transaction (BEGIN/COMMIT)». Перепроверено перед этой
|
||||
-- миграцией: .forgejo/workflows/deploy-tradein.yml, шаг применения миграций
|
||||
-- (`for sql_file in ...; psql -v ON_ERROR_STOP=on < "$sql_file"`) НЕ добавляет
|
||||
-- собственный BEGIN/COMMIT и не передаёт `-1`/`--single-transaction` — транзакция
|
||||
-- в тех файлах возникала ТОЛЬКО из-за их же собственных BEGIN;...COMMIT; внутри
|
||||
-- файла, не из-за механизма деплоя. Эмпирическое подтверждение: в data/sql уже
|
||||
-- есть применённые на проде миграции без BEGIN/COMMIT вовсе (003_seed_deals.sql,
|
||||
-- 005_geocode_tracking.sql, 218_scrape_runs_ban_kind.sql,
|
||||
-- 223_scrape_runs_time_columns_meaning.sql) — psql выполняет их операторы с
|
||||
-- autocommit по одному, деплой не падает. Поэтому здесь BEGIN/COMMIT сознательно
|
||||
-- ОПУЩЕН: файл — это один самостоятельный CREATE INDEX CONCURRENTLY, выполняемый
|
||||
-- psql в autocommit-режиме.
|
||||
--
|
||||
-- Идемпотентно: IF NOT EXISTS. (Единственный неидемпотентный случай — если
|
||||
-- предыдущая попытка CONCURRENTLY была прервана и оставила INVALID индекс с тем
|
||||
-- же именем; тогда IF NOT EXISTS молча НЕ пересоздаст его валидным, и потребуется
|
||||
-- ручной `DROP INDEX CONCURRENTLY idx_lss_run_id;` перед повтором — это штатное
|
||||
-- поведение CONCURRENTLY, не специфика этого файла.)
|
||||
-- Dependencies: 079_listing_source_history.sql (создала таблицу и оба FK).
|
||||
-- Deploy order: standalone, независим от 222_db_audit_cleanup.sql.
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lss_run_id
|
||||
ON listing_source_snapshots (run_id);
|
||||
|
|
@ -230,4 +230,13 @@
|
|||
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
|
||||
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
|
||||
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
|
||||
#
|
||||
# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
|
||||
# правки они уже слиты в main и применены на проде (см. _schema_migrations),
|
||||
# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
|
||||
# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
|
||||
# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
|
||||
# 222/225 по прецеденту 233_payments.sql.
|
||||
222_db_audit_cleanup.sql
|
||||
225_listing_source_snapshots_run_id_idx.sql
|
||||
233_payments.sql
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue