All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI / backend-tests (pull_request) Successful in 17m46s
feature['id'] в WFS-ответе — сессионный fid, новый на каждый GetFeature. ON CONFLICT (source, external_id) не срабатывал ни разу, каждый weekly-прогон дописывал полный комплект ~488 фич: 4880 строк на 481 ЦП. Задуманный sha1-фолбэк по атрибутам был мёртв — fid присутствует всегда. Ключ теперь считается ТОЛЬКО по стабильным атрибутам (нормализованное имя, класс напряжения, координаты в 1e-5 градуса), fid игнорируется. Координата квантуется в целое, а не форматируется как float: ключ обязан совпадать байт-в-байт с бэкфиллом в SQL. sha256 вместо sha1 — встроен в PG16, pgcrypto не нужен. Починка разбора старые строки не убирает (ключи не совпадут, ON CONFLICT ничего не перезапишет) → data/sql/99c_power_supply_centers_dedup.sql: пересчёт ключа существующих строк + схлопывание копий (победитель — свежайший snapshot, NULLS LAST явно), с печатью чисел до/после и идемпотентностью. Refs #3322
125 lines
6.8 KiB
PL/PgSQL
125 lines
6.8 KiB
PL/PgSQL
-- 99c_power_supply_centers_dedup.sql
|
||
-- Issue #3322 — power_supply_centers раздут ×10: 4880 строк на 481 уникальный ЦП.
|
||
--
|
||
-- Причина. rosseti_wfs_loader брал external_id из feature['id'] WFS-ответа, а
|
||
-- GeoServer отдаёт СЕССИОННЫЙ fid (новый на каждый GetFeature) → ON CONFLICT
|
||
-- (source, external_id) не срабатывал ни разу, каждый weekly-прогон добавлял
|
||
-- полный набор ~488 фич заново. Починка разбора сама старые строки не убирает
|
||
-- (ON CONFLICT ничего не перезапишет, ключи не совпадут) → нужен этот бэкфилл.
|
||
--
|
||
-- Что делает файл:
|
||
-- (а) пересчитывает external_id по НОВОЙ формуле (см. ниже) для всех строк
|
||
-- source='rosseti_wfs';
|
||
-- (б) схлопывает копии: победитель группы — свежайший снапшот
|
||
-- (fetched_at DESC NULLS LAST, id DESC — DESC в PG это NULLS FIRST,
|
||
-- поэтому NULLS LAST задан ЯВНО);
|
||
-- (в) печатает числа: строк до / после, удалено, переключено на новый ключ.
|
||
-- Ожидание после прогона — ~481-488 строк (столько ЦП отдаёт источник).
|
||
-- (г) идемпотентен: на повторном прогоне ключи уже совпадают → 0 удалений,
|
||
-- 0 обновлений, «до» = «после».
|
||
--
|
||
-- ФОРМУЛА КЛЮЧА (дублирует rosseti_wfs_loader._stable_external_id — менять только
|
||
-- синхронно, иначе следующий weekly-прогон вставит второй комплект строк):
|
||
-- seed = sc_name_norm || '|' || voltage_class || '|' || lon_e5 || '|' || lat_e5
|
||
-- external_id = 'h:' || left(hex(sha256(utf8(seed))), 16)
|
||
-- где lon_e5/lat_e5 — координата в единицах 1e-5 градуса (~1 м), округление
|
||
-- round() = half-away-from-zero (в питоне ровно то же: floor(|v|*1e5+0.5) со знаком);
|
||
-- пустая строка, если geom отсутствует. Целые, а не форматированный float —
|
||
-- текстовое представление double в питоне и в PG различается.
|
||
-- sha256, а не sha1: sha256 встроен в PG16, sha1 потребовал бы pgcrypto.
|
||
--
|
||
-- Байт-в-байт совпадение с питоном держится на том, что SQL НИЧЕГО не нормализует
|
||
-- сам: sc_name_norm и voltage_class — уже готовые колонки, их записал тот же
|
||
-- normalize_sc_name / parse_voltage_class. Если normalize_sc_name когда-нибудь
|
||
-- изменится, старые sc_name_norm разъедутся с новыми ключами — тогда нужен
|
||
-- повторный прогон логики этого файла (он идемпотентен, ре-apply безопасен).
|
||
--
|
||
-- Порядок: миграция ПЕРЕД деплоем кода (schema-first) — новый код после неё
|
||
-- попадает ON CONFLICT-ом в уже схлопнутые строки.
|
||
--
|
||
-- Naming: deploy.yml применяет файлы по `ls -1 data/sql/*.sql | sort`;
|
||
-- '99c_' идёт после '99b_grant_quarter_price_index_fdw.sql' ('b' < 'c').
|
||
|
||
BEGIN;
|
||
|
||
SET LOCAL lock_timeout = '5s';
|
||
|
||
DO $$
|
||
DECLARE
|
||
rows_before bigint;
|
||
names_before bigint;
|
||
rows_after bigint;
|
||
names_after bigint;
|
||
deleted bigint;
|
||
rekeyed bigint;
|
||
BEGIN
|
||
SELECT count(*), count(DISTINCT sc_name_norm)
|
||
INTO rows_before, names_before
|
||
FROM power_supply_centers
|
||
WHERE source = 'rosseti_wfs';
|
||
|
||
CREATE TEMP TABLE psc_new_key ON COMMIT DROP AS
|
||
SELECT
|
||
id,
|
||
fetched_at,
|
||
'h:' || substring(
|
||
encode(
|
||
sha256(convert_to(
|
||
sc_name_norm
|
||
|| '|' || coalesce(voltage_class, '')
|
||
|| '|' || CASE WHEN geom IS NULL THEN ''
|
||
ELSE round(ST_X(geom)::numeric * 100000)::text END
|
||
|| '|' || CASE WHEN geom IS NULL THEN ''
|
||
ELSE round(ST_Y(geom)::numeric * 100000)::text END,
|
||
'UTF8'
|
||
)),
|
||
'hex'
|
||
) FROM 1 FOR 16
|
||
) AS new_key
|
||
FROM power_supply_centers
|
||
WHERE source = 'rosseti_wfs';
|
||
|
||
-- (б) схлопывание: оставляем свежайший снапшот каждой группы.
|
||
-- Резервы (reserve_mva и пр.) не теряются: rosseti/eesk-лоадеры пишут их
|
||
-- UPDATE-ом по sc_name_norm, т.е. во ВСЕ копии сразу, победитель их несёт.
|
||
WITH ranked AS (
|
||
SELECT
|
||
id,
|
||
row_number() OVER (
|
||
PARTITION BY new_key
|
||
ORDER BY fetched_at DESC NULLS LAST, id DESC
|
||
) AS rn
|
||
FROM psc_new_key
|
||
)
|
||
DELETE FROM power_supply_centers p
|
||
USING ranked r
|
||
WHERE p.id = r.id
|
||
AND r.rn > 1;
|
||
GET DIAGNOSTICS deleted = ROW_COUNT;
|
||
|
||
-- (а) пересчёт ключа у выживших. После DELETE каждый new_key принадлежит
|
||
-- ровно одной строке → UNIQUE (source, external_id) не нарушается.
|
||
-- IS DISTINCT FROM даёт идемпотентность: второй прогон обновит 0 строк.
|
||
UPDATE power_supply_centers p
|
||
SET external_id = k.new_key
|
||
FROM psc_new_key k
|
||
WHERE p.id = k.id
|
||
AND p.external_id IS DISTINCT FROM k.new_key;
|
||
GET DIAGNOSTICS rekeyed = ROW_COUNT;
|
||
|
||
SELECT count(*), count(DISTINCT sc_name_norm)
|
||
INTO rows_after, names_after
|
||
FROM power_supply_centers
|
||
WHERE source = 'rosseti_wfs';
|
||
|
||
RAISE NOTICE '#3322 power_supply_centers: было % строк / % имён -> стало % строк / % имён (удалено %, переключено на стабильный ключ %)',
|
||
rows_before, names_before, rows_after, names_after, deleted, rekeyed;
|
||
|
||
-- 700 — потолок здравого смысла: источник отдаёт ~488 ЦП по области.
|
||
-- Превышение = формула ключа не схлопнула дубли, смотреть глазами.
|
||
IF rows_after > 700 THEN
|
||
RAISE WARNING '#3322: после дедупа осталось % строк (ожидалось ~481-488) — проверить формулу ключа', rows_after;
|
||
END IF;
|
||
END $$;
|
||
|
||
COMMIT;
|