fix(migration-133): dedup via cascade-delete, not child re-point (prod hotfix) #1914

Merged
bot-backend merged 1 commit from fix/migration-133-offer-price-history-collision into main 2026-06-26 08:16:28 +00:00

View file

@ -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