gendesign/tradein-mvp/backend/data/sql/230_house_merge_log.sql
bot-backend c86a5378ef
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 1m31s
feat(tradein/houses): журнал слияний домов — слияние стало обратимым (#2740)
2026-08-06 15:41:20 +00:00

262 lines
20 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 230_house_merge_log.sql
-- Журнал слияний домов + обратная операция (#2690).
--
-- WHY:
-- `house_dedup_merge` — НЕ спящая идея, а живой деструктивный проход: расписание
-- `house_dedup_merge` на проде enabled=true, dry_run=false, такт 7 дней. Шесть прогонов
-- с 2026-06-27 уже удалили 119 строк `houses` (счётчики losers_deleted в scrape_runs:
-- 2/39/31/9/6/32). Единственным следом «кто в кого» была строка `logger.info` в контейнере,
-- а логи ротируются быстрее суток. То есть **уже сегодня** нельзя назвать, какой дом в какой
-- свернули 1 августа, — не говоря о том, чтобы вернуть.
--
-- Пока этого журнала нет, любой разговор о расширении ключа схлопывания (#2690, #1772)
-- ведётся без права на ошибку: единственный откат — restore всей БД на момент до прогона,
-- т.е. выброс недели сбора. Журнал снимает это условие: слияние становится обратимым,
-- и вопрос о ключе можно пересматривать, а не решать «навсегда».
--
-- Правку НЕ следует читать как одобрение текущего ключа/победителя/гео-стража. Она к ним
-- НЕЙТРАЛЬНА: ни ключ, ни правило выбора победителя, ни гео-страж здесь не меняются.
-- Меняется только одно — теперь есть что откатить.
--
-- WHAT (одна строка = один проигравший дом):
-- merge_pass / cluster_key / geo_guard / distance_m — ОСНОВАНИЕ слияния. Это не косметика:
-- ровно этих полей не хватило в #2690, чтобы ответить на вопрос «сколько слияний прошло
-- на расстояниях, которые гео-страж заблокировал бы» по ДАННЫМ, а не по ревью. distance_m
-- пишется всегда, даже когда страж для прохода выключен (fias-проход) — тогда он и есть
-- единственная запись о том, насколько далеко разъехались объединённые дома.
-- loser_row — ПОЛНЫЙ jsonb-снимок удаляемой строки (`to_jsonb(h.*)`, все 86 колонок).
-- Ссылка на удалённую строку бесполезна, поэтому хранится содержимое. Снимок целиком,
-- а не список полей: проверено, что `jsonb_populate_record(NULL::houses, loser_row)`
-- восстанавливает строку побайтово, включая PostGIS-geom (to_jsonb отдаёт её GeoJSON'ом,
-- populate_record разбирает обратно входной функцией типа). Побочная выгода: новая
-- колонка в `houses` попадает в снимок и в откат САМА, без правки этой миграции.
-- keeper_before — снимок ПОБЕДИТЕЛЯ до переноса метаданных. Нужен, потому что слияние не
-- только удаляет проигравшего: `_CARRY_OVER_IDENTITY_SQL` дозаполняет победителю NULL-поля
-- идентичности (fias/кадастр/ГАР/DaData) значениями проигравшего. Без этого снимка откат
-- вернул бы дом, но оставил бы его ФИАС на победителе — и следующий же fias-проход слил
-- бы их обратно.
-- children_repointed — {"таблица.колонка": [id, ...]}. Дочерние строки ПЕРЕЖИЛИ слияние,
-- у них сменилась только ссылка, поэтому хранятся id, а не содержимое (иначе одни
-- listings с их raw-payload'ом дали бы ~7 КБ на строку вместо ~8 байт на id).
-- children_deleted — {"таблица": [{строка целиком}, ...]}. Дочерние строки, которые проход
-- УДАЛИЛ из-за коллизии по UNIQUE. Их содержимое уничтожено, id недостаточно — только
-- полный снимок. Таких таблиц шесть (см. _STEPS), строки мелкие.
-- batch_id — один вызов merge_duplicate_houses() (оба прохода). Единица отката.
-- run_id / initiator — кто инициировал: scrape_runs.id для расписания, NULL для ручного.
--
-- НАМЕРЕННО БЕЗ ВНЕШНИХ КЛЮЧЕЙ на houses(id) и scrape_runs(id):
-- журнал обязан ПЕРЕЖИВАТЬ строки, которые описывает. loser_id указывает на заведомо
-- удалённый дом. keeper_id — на дом, который сам может быть слит следующим прогоном; FK
-- с CASCADE стёр бы историю ровно тогда, когда она нужнее всего, а FK без CASCADE
-- заблокировал бы слияние. То же с run_id: чистка scrape_runs не должна трогать журнал.
--
-- ОБЪЁМ (замерено на проде 2026-08-06):
-- 9 571 дом, средняя строка houses в jsonb 2 581 Б. Строка журнала ≈ loser_row 2.5 КБ +
-- keeper_before 2.5 КБ + списки id (в среднем 27.9 дочерних строк на дом × ~8 Б) ≈ 5.3 КБ.
-- Наблюдаемый темп — 20 слияний в неделю (119 за 6 прогонов) ≈ 106 КБ/нед ≈ 5.5 МБ/год.
-- Ближайший прогон (замер тем же выражением, что и код): 93 проигравших ≈ 0.5 МБ.
-- Абсолютный потолок, если схлопнуть вообще все дома: 9 571 × 5.3 КБ ≈ 50 МБ против 23 МБ
-- самой таблицы houses.
--
-- RETENTION: НЕ НУЖЕН, сознательно. Потолок роста — двузначные мегабайты, то есть дешевле
-- любой процедуры чистки; а журнал слияний — это ровно то, что удалять не хочется: его
-- ценность в том, что он отвечает на вопрос «что было год назад», когда логов давно нет.
-- Если объём когда-нибудь станет проблемой, удалять надо не строки, а тяжёлые снимки
-- (loser_row/keeper_before → NULL) у записей старше N лет, сохранив соответствие
-- loser→keeper: оно весит байты и именно оно нужно дольше всего.
--
-- Dependencies: 002_core_tables.sql (houses), 135_scrape_schedules_seed_house_dedup_merge.sql
-- Пишется в ТОЙ ЖЕ транзакции, что и слияние (см. house_dedup_merge._run_merge_pass) —
-- разрыв «слияние прошло, запись не легла» невозможен по построению; dry_run откатывает и то,
-- и другое вместе.
BEGIN;
CREATE TABLE IF NOT EXISTS house_merge_log (
id bigserial PRIMARY KEY,
merged_at timestamptz NOT NULL DEFAULT now(),
batch_id uuid NOT NULL,
run_id bigint,
initiator text NOT NULL,
merge_pass text NOT NULL,
cluster_key text NOT NULL,
geo_guard boolean NOT NULL,
distance_m double precision,
norm_address text,
loser_id bigint NOT NULL,
keeper_id bigint NOT NULL,
loser_row jsonb NOT NULL,
keeper_before jsonb NOT NULL,
children_repointed jsonb NOT NULL DEFAULT '{}'::jsonb,
children_deleted jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_house_merge_log_loser ON house_merge_log (loser_id);
CREATE INDEX IF NOT EXISTS idx_house_merge_log_keeper ON house_merge_log (keeper_id);
CREATE INDEX IF NOT EXISTS idx_house_merge_log_batch ON house_merge_log (batch_id);
COMMENT ON TABLE house_merge_log IS
'Журнал слияний домов (#2690): одна строка = один проигравший дом, удалённый проходом '
'house_dedup_merge. Пишется в ТОЙ ЖЕ транзакции, что и слияние. Содержит полный снимок '
'удалённой строки и перечень перенесённых/удалённых дочерних строк — достаточно, чтобы '
'назвать поимённо, что во что свернули, и вернуть обратно (house_merge_undo). Намеренно '
'БЕЗ FK на houses/scrape_runs: журнал переживает строки, которые описывает. Retention нет.';
COMMENT ON COLUMN house_merge_log.cluster_key IS
'ЗНАЧЕНИЕ ключа, по которому дома попали в один кластер («addr:вайнера66» / «fias:<uuid>»), '
'а не имя ключа — по нему видно, какое именно совпадение сработало.';
COMMENT ON COLUMN house_merge_log.geo_guard IS
'Был ли для этого прохода включён гео-страж 250 м. false = слияние разрешено БЕЗ проверки '
'близости; вместе с distance_m это и есть аудит основания (#2690).';
COMMENT ON COLUMN house_merge_log.distance_m IS
'ST_DistanceSphere между победителем и проигравшим на момент слияния; NULL = у одной из '
'сторон не было geom. Пишется ВСЕГДА, в том числе когда гео-страж выключен.';
COMMENT ON COLUMN house_merge_log.loser_row IS
'to_jsonb() удалённой строки houses целиком. Восстановление: '
'INSERT INTO houses SELECT r.* FROM jsonb_populate_record(NULL::houses, loser_row) r.';
COMMENT ON COLUMN house_merge_log.keeper_before IS
'Снимок победителя ДО переноса метаданных с проигравшего (COALESCE-дозаполнение полей '
'идентичности). Без него откат вернул бы дом, но оставил его ФИАС/кадастр на победителе.';
COMMENT ON COLUMN house_merge_log.children_repointed IS
'{"таблица.колонка": [id, ...]} — дочерние строки, у которых слияние сменило ссылку '
'loser→keeper. Строки целы, поэтому хранятся id: откат возвращает ссылку обратно.';
COMMENT ON COLUMN house_merge_log.children_deleted IS
'{"таблица": [{строка целиком}, ...]} — дочерние строки, УДАЛЁННЫЕ проходом из-за коллизии '
'по UNIQUE с победителем. Содержимое уничтожено, поэтому хранится снимок, а не id.';
-- ── Обратная операция ────────────────────────────────────────────────────────
--
-- Откат одного батча (или его части) по журналу. Транзакционен: вызывающий сам решает
-- COMMIT/ROLLBACK, увидев отчёт. Возвращает СТРОКУ НА КАЖДУЮ запись журнала со статусом —
-- в том числе «не смог», потому что молчаливо-успешный откат хуже отсутствующего.
--
-- Порядок внутри одной записи важен: сначала воскресить дом (на него ссылаются дети), потом
-- вернуть ссылки детей, потом вернуть удалённых детей, потом снять перенос метаданных с
-- победителя. Записи батча обходятся в обратном порядке (id DESC) — если дом A слили в B,
-- а B потом в C, разматывать надо с конца.
--
-- ИЗВЕСТНЫЕ ГРАНИЦЫ (сознательные, отражены в статусе):
-- * дочерняя строка, удалённая по коллизии, может не вернуться: место в UNIQUE-ключе занято
-- строкой победителя. ON CONFLICT DO NOTHING + счётчик в статусе, а не тихая потеря;
-- * backfill-строки house_sources/house_address_aliases, которые проход дописал победителю,
-- НЕ удаляются: они собраны из собственных полей победителя и остались бы верны и без
-- слияния;
-- * если id проигравшего уже занят — запись пропускается со статусом, откат не гадает.
CREATE OR REPLACE FUNCTION house_merge_undo(
p_batch uuid,
p_only_losers bigint[] DEFAULT NULL
)
RETURNS TABLE (
out_log_id bigint,
out_loser_id bigint,
out_keeper_id bigint,
out_status text
)
LANGUAGE plpgsql
AS $$
DECLARE
rec record;
v_table text;
v_column text;
v_ids bigint[];
v_rows jsonb;
v_field text;
v_repointed int;
v_restored int;
v_lost int;
v_n int;
-- Список полей ДОЛЖЕН совпадать с SET в house_dedup_merge._CARRY_OVER_IDENTITY_SQL;
-- за расхождением следит тест test_undo_carryover_fields_match_merge_carryover.
c_carry_fields constant text[] := ARRAY[
'house_fias_id', 'cadastral_number', 'gar_house_guid', 'gar_flat_count',
'gar_matched_at', 'gar_match_method', 'dadata_qc_geo', 'dadata_qc_house',
'dadata_enriched_at'
];
BEGIN
FOR rec IN
SELECT *
FROM house_merge_log l
WHERE l.batch_id = p_batch
AND (p_only_losers IS NULL OR l.loser_id = ANY (p_only_losers))
ORDER BY l.id DESC
LOOP
out_log_id := rec.id;
out_loser_id := rec.loser_id;
out_keeper_id := rec.keeper_id;
IF EXISTS (SELECT 1 FROM houses h WHERE h.id = rec.loser_id) THEN
out_status := 'skipped: houses.id ' || rec.loser_id || ' занят — уже откачено?';
RETURN NEXT;
CONTINUE;
END IF;
-- 1. Воскресить проигравшего целиком из снимка (все колонки, включая geom).
INSERT INTO houses
SELECT r.* FROM jsonb_populate_record(NULL::houses, rec.loser_row) r;
-- 2. Вернуть ссылки уцелевших детей. Условие «сейчас указывает на победителя»
-- защищает от затирания строк, которые после слияния перепривязали чем-то ещё.
v_repointed := 0;
FOR v_table, v_column, v_ids IN
SELECT split_part(e.key, '.', 1),
split_part(e.key, '.', 2),
ARRAY(SELECT jsonb_array_elements_text(e.value)::bigint)
FROM jsonb_each(rec.children_repointed) AS e
LOOP
EXECUTE format(
'UPDATE %I SET %I = $1 WHERE id = ANY ($2) AND %I = $3',
v_table, v_column, v_column
) USING rec.loser_id, v_ids, rec.keeper_id;
GET DIAGNOSTICS v_n = ROW_COUNT;
v_repointed := v_repointed + v_n;
END LOOP;
-- 3. Вернуть детей, удалённых по коллизии UNIQUE. Место могло остаться занятым
-- строкой победителя — тогда DO NOTHING, и это попадёт в отчёт как «не вернулось».
v_restored := 0;
v_lost := 0;
FOR v_table, v_rows IN
SELECT e.key, e.value FROM jsonb_each(rec.children_deleted) AS e
LOOP
EXECUTE format(
'INSERT INTO %I SELECT r.* FROM jsonb_array_elements($1) AS el, '
'LATERAL jsonb_populate_record(NULL::%I, el) r ON CONFLICT DO NOTHING',
v_table, v_table
) USING v_rows;
GET DIAGNOSTICS v_n = ROW_COUNT;
v_restored := v_restored + v_n;
v_lost := v_lost + (jsonb_array_length(v_rows) - v_n);
END LOOP;
-- 4. Снять перенос метаданных с победителя. Только там, где до слияния было NULL И
-- текущее значение всё ещё РОВНО то, что принёс этот проигравший: если поле успел
-- заполнить загрузчик (или донором был другой проигравший кластера) — не трогаем.
-- Сравнение в jsonb-пространстве, чтобы один цикл покрыл text/int/timestamptz.
FOREACH v_field IN ARRAY c_carry_fields LOOP
IF rec.keeper_before ->> v_field IS NULL THEN
EXECUTE format(
'UPDATE houses SET %I = NULL WHERE id = $1 AND to_jsonb(%I) = $2',
v_field, v_field
) USING rec.keeper_id, rec.loser_row -> v_field;
END IF;
END LOOP;
out_status := format(
'restored: дом %s вернулся, ссылок возвращено %s, дочерних строк восстановлено %s'
|| CASE WHEN v_lost > 0 THEN ', НЕ ВЕРНУЛОСЬ ' || v_lost || ' (место занято)'
ELSE '' END,
rec.loser_id, v_repointed, v_restored
);
RETURN NEXT;
END LOOP;
END;
$$;
COMMENT ON FUNCTION house_merge_undo(uuid, bigint[]) IS
'Откат слияния домов по журналу house_merge_log (#2690). Аргументы: batch_id (единица '
'отката = один вызов merge_duplicate_houses) и опциональный список loser_id для частичного '
'отката. Возвращает строку-статус на КАЖДУЮ запись журнала, включая неудачные. '
'Транзакции не открывает и не закрывает — вызывающий смотрит отчёт и решает COMMIT/ROLLBACK: '
' BEGIN; SELECT * FROM house_merge_undo(''<batch_id>''); -- прочитать статусы -- COMMIT;';
COMMIT;