diff --git a/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql new file mode 100644 index 00000000..35bfb069 --- /dev/null +++ b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql new file mode 100644 index 00000000..e783502e --- /dev/null +++ b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql @@ -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); diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d71c6d7..52a76097 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -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