fix(migration-133): dedup via cascade-delete, not child re-point (prod hotfix)
Migration 133 FAILED on prod: offer_price_history has UNIQUE (listing_id, change_time), but the re-point UPDATE moved ALL loser rows to the keeper → duplicate key violation (the deep-review validated on a throwaway schema subset that lacked this constraint). A keeper-only NOT EXISTS guard is also insufficient — multiple losers in one (source,source_id) group share a change_time, so they collide WITH EACH OTHER inside the single UPDATE (same vulnerability for listings_snapshots PK + external_valuations partial-UNIQUE). Fix: drop all data-child re-points; rely on FK ON DELETE CASCADE (all four FKs are confdeltype='c') to clean loser children when losers are deleted. Keep only the collision-free merged_into re-point. These are duplicates of the SAME offer — the keeper retains its own full history; loser-dup history is redundant. Dry-run on prod (BEGIN…ROLLBACK): 9840 groups, 9889 losers deleted, index created, abort-guard passed, zero errors. Migration 133 never applied anywhere (absent from _schema_migrations), so amending it in place is safe. Refs #1773
This commit is contained in:
parent
1d5b081cb4
commit
f1141f3578
1 changed files with 27 additions and 61 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue