From e0cd378d192b42b02d92d3f567c1c8817f941969 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 26 Jun 2026 08:16:27 +0000 Subject: [PATCH] fix(migration-133): dedup via cascade-delete, not child re-point (prod hotfix) Migration 133 failed on prod (offer_price_history UNIQUE (listing_id,change_time) + multi-loser collisions). Drop data-child re-points, rely on FK ON DELETE CASCADE; keep only merged_into. Dry-run on prod: 9840 groups, 9889 losers deleted, clean. Refs #1773 --- .../sql/133_listings_uq_source_source_id.sql | 88 ++++++------------- 1 file changed, 27 insertions(+), 61 deletions(-) diff --git a/tradein-mvp/backend/data/sql/133_listings_uq_source_source_id.sql b/tradein-mvp/backend/data/sql/133_listings_uq_source_source_id.sql index 5f66d11b..e8cf225c 100644 --- a/tradein-mvp/backend/data/sql/133_listings_uq_source_source_id.sql +++ b/tradein-mvp/backend/data/sql/133_listings_uq_source_source_id.sql @@ -19,21 +19,23 @@ -- keeper = приоритет (house_id_fk IS NOT NULL) DESC, -- затем is_active DESC, затем last_seen_at DESC, затем id DESC. -- То есть оставляем строку, уже привязанную к дому и самую свежую. --- 2) Перепривязываем дочерние записи (offer_price_history, listings_snapshots, --- listing_sources, external_valuations) и self-ref listings.merged_into --- с loser-строк на keeper — collision-safe (WHERE NOT EXISTS), чтобы не --- нарушить их собственные UNIQUE/PK. Остаток (коллизии — у keeper уже есть --- эквивалент) уедет CASCADE при удалении loser. --- 3) Удаляем loser-строки (FK ON DELETE CASCADE подчищает остаточных детей). +-- 2) Переносим на keeper только self-ref listings.merged_into (нет UNIQUE с +-- listing_id → без коллизий). Дочерние ДАННЫЕ loser'ов (offer_price_history, +-- listings_snapshots, listing_sources, external_valuations) НЕ переносим: +-- их UNIQUE/PK включают listing_id, а несколько loser'ов одной группы несут +-- одинаковый change_time/snapshot_date → перенос на keeper в одном UPDATE +-- коллидирует МЕЖДУ loser'ами (прод-факт: offer_price_history_listing_change_uq). +-- Это дубли одного оффера — keeper хранит свою полную историю; loser-дубли +-- избыточны. +-- 3) Удаляем loser-строки. FK ON DELETE CASCADE подчищает ВСЕХ их детей. -- 4) Вешаем UNIQUE-индекс на (source, source_id) WHERE source_id IS NOT NULL — -- частичный, т.к. источники без source_id (yandex/url-only) дают source_id -- NULL, а NULL в Postgres-UNIQUE считаются различными → их не трогаем. -- -- IDEMPOTENCY / SAFETY (важно для прода): -- - Весь файл в одной транзакции BEGIN/COMMIT — атомарно. --- - Дедуп НЕДЕСТРУКТИВЕН для keeper: его дети сохраняются полностью, удаляются --- только дубли-loser и их (коллизионные) дети, у которых keeper уже имеет --- эквивалент. +-- - keeper и все ЕГО дети сохраняются полностью. Удаляются только дубли-loser +-- и их дети (CASCADE) — это история одного и того же оффера, у keeper своя. -- - Повторный прогон: после первого раза дублей нет → дедуп-шаги no-op; -- индекс создаётся через CREATE UNIQUE INDEX IF NOT EXISTS → no-op. -- - source_id IS NULL строки полностью игнорируются (частичный индекс). @@ -107,65 +109,29 @@ BEGIN RAISE NOTICE 'migration 133: no duplicate (source, source_id) groups — dedup no-op'; ELSE -- ----------------------------------------------------------------- - -- 1. Перепривязка дочерних записей loser -> keeper (collision-safe). - -- Где у keeper уже есть запись с тем же UNIQUE/PK-ключом — - -- loser-строку НЕ двигаем (она уедет CASCADE на шаге 3). + -- 1. self-ref merged_into: loser → keeper. Это единственный безопасный + -- перенос — у merged_into нет UNIQUE-ключа с listing_id, коллизий нет. + -- + -- Дочерние ДАННЫЕ loser'ов (offer_price_history, listings_snapshots, + -- listing_sources, external_valuations) НЕ переносим: все их UNIQUE/PK + -- включают listing_id, и НЕСКОЛЬКО loser'ов одной группы могут нести + -- одинаковый change_time/snapshot_date/(source,house_id) → перенос на + -- keeper в одном UPDATE даёт коллизию МЕЖДУ САМИМИ loser'ами (а не только + -- с keeper'ом), которую NOT EXISTS против keeper'а не ловит (прод-факт: + -- offer_price_history_listing_change_uq). Это дубли ОДНОГО оффера — + -- keeper хранит свою полную историю, история loser-дублей избыточна. + -- Все FK имеют ON DELETE CASCADE (confdeltype='c') → дочерние loser-строки + -- удаляются автоматически при DELETE loser на шаге 2. Без коллизий. -- ----------------------------------------------------------------- - - -- offer_price_history: нет UNIQUE на listing_id → двигаем все. - UPDATE offer_price_history o - SET listing_id = m.keeper_id - FROM _ld_map m - WHERE o.listing_id = m.loser_id; - - -- listings_snapshots: PK (listing_id, snapshot_date) → не двигаем дни, - -- которые у keeper уже есть. - UPDATE listings_snapshots s - SET listing_id = m.keeper_id - FROM _ld_map m - WHERE s.listing_id = m.loser_id - AND NOT EXISTS ( - SELECT 1 FROM listings_snapshots s2 - WHERE s2.listing_id = m.keeper_id - AND s2.snapshot_date = s.snapshot_date - ); - - -- listing_sources: UNIQUE (ext_source, ext_id) → не двигаем те, что - -- у keeper уже есть. - UPDATE listing_sources ls - SET listing_id = m.keeper_id - FROM _ld_map m - WHERE ls.listing_id = m.loser_id - AND NOT EXISTS ( - SELECT 1 FROM listing_sources ls2 - WHERE ls2.listing_id = m.keeper_id - AND ls2.ext_source = ls.ext_source - AND ls2.ext_id = ls.ext_id - ); - - -- external_valuations: partial UNIQUE (source, house_id, listing_id) - -- WHERE house_id IS NOT NULL AND listing_id IS NOT NULL. - UPDATE external_valuations ev - SET listing_id = m.keeper_id - FROM _ld_map m - WHERE ev.listing_id = m.loser_id - AND NOT EXISTS ( - SELECT 1 FROM external_valuations ev2 - WHERE ev2.listing_id = m.keeper_id - AND ev2.source = ev.source - AND ev2.house_id IS NOT DISTINCT FROM ev.house_id - ); - - -- self-ref: listings.merged_into указывает на loser → переводим на keeper. UPDATE listings l SET merged_into = m.keeper_id FROM _ld_map m WHERE l.merged_into = m.loser_id; -- ----------------------------------------------------------------- - -- 2. Удаляем loser-строки. FK ON DELETE CASCADE подчистит остаточных - -- детей (коллизии, не перенесённые на шаге 1) — у keeper для них - -- уже есть эквивалент. + -- 2. Удаляем loser-строки. FK ON DELETE CASCADE подчищает всех их детей + -- (offer_price_history / listings_snapshots / listing_sources / + -- external_valuations) — у keeper своя полная история. -- ----------------------------------------------------------------- DELETE FROM listings l USING _ld_map m