gendesign/data/sql/128_supply_layers_dev_group_key.sql
Light1YT ed3b302d57
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m22s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m4s
fix(supply-layers): thread dev_group_name into L3 upsert key (#970 CRITICAL)
REOPENED. L3 future-supply rows are computed per (district_name, dev_group_name)
but dev_group_name was never a key column — only embedded in method text. With
complex_id/obj_class NULL for L3, every dev_group of a district collapsed to one
upsert key → ~95.6% loss. Ground-truth (Академический, prod): should be 13,808
units / 15 dev_groups / 54 objects; only 1 row / 607 units survived.

Migration 128: ADD COLUMN supply_layers.dev_group_name TEXT + rebuild
uq_supply_layers_logical to (layer, district_name, complex_id, obj_class,
dev_group_name, source, snapshot_date) NULLS NOT DISTINCT (L1/L2 dev_group_name
NULL stays transparent → their dedup unchanged; L3 distinct groups no longer
collapse). Dry-run-verified vs prod catalog (applies clean, ROLLBACK clean).

Worker: SupplyLayerRow gains dev_group_name (L1/L2=None, L3=group); _UPSERT_SQL
adds it to INSERT/VALUES (CAST(:dev_group_name AS text)) + ON CONFLICT (key col,
not in DO UPDATE SET). Service+worker regression tests assert same-district/
different-dev_group → distinct keys (no collapse). 234 supply tests pass.

Deploy applies migration before container restart; collapsed data self-heals on
next supply_layers_refresh. Verification = prod re-measure post-deploy.

Refs #970
2026-06-04 10:06:30 +05:00

99 lines
8.4 KiB
PL/PgSQL
Raw Permalink 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.

-- 128_supply_layers_dev_group_key.sql
-- #970 (REOPENED, CRITICAL) — Layer-3 future-supply collapse: добавляем dev_group_name в зерно
-- и в логический UNIQUE-ключ supply_layers, чтобы dev_group'ы района перестали схлопываться.
--
-- БАГ (ground-truth, измерено на prod):
-- Layer-3 (future supply) в supply_layers.py (_L3_FUTURE_SQL) считается per
-- (district_name, dev_group_name) — GROUP BY district_name, dev_group_name — но строки
-- эмитятся с complex_id=NULL и БЕЗ dev_group_name (колонки нет с м.125). Логический ключ
-- uq_supply_layers_logical (м.125) = (layer, district_name, complex_id, obj_class, source,
-- snapshot_date) NULLS NOT DISTINCT — dev_group_name в нём НЕТ. Итог: ВСЕ dev_group'ы района
-- дают ОДИН и тот же ключ (complex_id всегда NULL для L3) → UPSERT воркера
-- (supply_layers_refresh.py, ON CONFLICT) перезатирает их друг другом → массовая потеря.
-- Измерено (Академический, latest snapshot): должно быть 13 808 юнитов в 15 dev_group'ах /
-- 54 объектах, выживает 1 строка / 607 юнитов (~95.6% потерь, недетерминированно per-run).
--
-- ЧИНИМ (схема): dev_group_name становится частью L3-grain и частью логического ключа.
-- 1. ADD COLUMN dev_group_name TEXT (nullable — у L1/L2 dev_group'а НЕТ, его ставит только L3).
-- 2. Перестроить uq_supply_layers_logical, ДОБАВив dev_group_name:
-- (layer, district_name, complex_id, obj_class, dev_group_name, source, snapshot_date)
-- по-прежнему UNIQUE NULLS NOT DISTINCT (PG15+, как м.125).
--
-- Почему NULLS NOT DISTINCT сохраняется (КРИТИЧНО — корректность дедупа обоих случаев):
-- В обычном UNIQUE Postgres считает NULL DISTINCT → (а) region-wide / L1/L2 строки
-- (district_name/complex_id/obj_class/dev_group_name IS NULL) дублировались бы, (б) ON CONFLICT
-- их НЕ матчил бы (NULL <> NULL). NULLS NOT DISTINCT сравнивает NULL'ы как РАВНЫЕ. Следствия:
-- • L1/L2 строки: dev_group_name=NULL у ВСЕХ из них → новая колонка для них «прозрачна»,
-- они продолжают дедуплиться по ОСТАЛЬНОЙ части ключа ровно как раньше (NULL=NULL по
-- dev_group_name не ломает их уникальность). Семантика L1/L2 НЕ меняется.
-- • L3 строки: теперь несут РАЗНЫЕ непустые dev_group_name → дают РАЗНЫЕ ключи → больше
-- НЕ схлопываются; разные dev_group'ы района сосуществуют (15 строк вместо 1).
-- Прецеденты NULLS NOT DISTINCT в репо: м.125 (исходный ключ), м.110 (uq_infra_dedupe).
--
-- Зависимости / окружение:
-- • supply_layers (м.125) — ALTER существующей таблицы (ADD COLUMN + rebuild constraint).
-- • PostgreSQL 16 (postgis/postgis:16-3.4) — нужен для UNIQUE NULLS NOT DISTINCT (PG15+).
-- • Unique-constraint сам по себе даёт индекс — отдельный supporting-индекс под этот ключ
-- НЕ нужен (м.125 его и не заводила; read-path индексы idx_supply_layers_* остаются как есть).
--
-- Idempotent (acceptance — ре-applied чисто ДВАЖДЫ): ADD COLUMN IF NOT EXISTS + guarded DROP/ADD
-- констрейнта через DO-блок с проверкой pg_constraint (мирроринг м.125 / м.40). Re-apply
-- конвергентен. BEGIN/COMMIT — атомарно. НИКАКИХ DROP TABLE/TRUNCATE, никаких INSERT/строк.
--
-- Deploy order (КРИТИЧНО): эта SQL-миграция применяется ПЕРВОЙ (auto-apply через deploy.yml /
-- _schema_migrations tracking, ровно один раз по NN). ЗАТЕМ — отдельным backend-изменением
-- (НЕ в этом PR) воркер backend/app/workers/tasks/supply_layers_refresh.py:
-- • _UPSERT_SQL INSERT column-list + VALUES должны ДОБАВИТЬ dev_group_name
-- (CAST(:dev_group_name AS text)),
-- • ON CONFLICT (...) должен стать
-- (layer, district_name, complex_id, obj_class, dev_group_name, source, snapshot_date) —
-- ровно под новый ключ,
-- • а supply_layers.py L3-emit (SupplyLayerRow, layer=3) должен ПОПУЛИРОВАТЬ dev_group_name
-- (сейчас он только пишет dev_group в method-строку, в саму строку — нет).
-- Никогда наоборот: воркер с новым ON CONFLICT упадёт без этой колонки/ключа.
-- Существующие схлопнутые данные САМО-залечиваются на следующем supply_layers_refresh —
-- воркер перезапишет район строками по ИСПРАВЛЕННОМУ ключу (15 dev_group'ов вместо 1).
BEGIN;
-- ── 1. Новая колонка dev_group_name (L3-grain) ─────────────────────────────────
-- Nullable: L1/L2 dev_group'а не имеют (ставит только L3 = бакет future-supply по группе
-- застройщика). ADD COLUMN IF NOT EXISTS → re-apply пропускает добавление (safe).
ALTER TABLE supply_layers
ADD COLUMN IF NOT EXISTS dev_group_name TEXT;
COMMENT ON COLUMN supply_layers.dev_group_name IS
'L3-grain (#970): бакет future-supply по группе застройщика (dev_group_name, м.113). '
'Часть логического ключа uq_supply_layers_logical для L3 (domrf_multiphase). NULL для '
'L1/L2 (у них dev_group''а нет). Без этой колонки в ключе все dev_group''ы района '
'схлопывались в один NULL-key и UPSERT их перезатирал (~95.6% потерь L3).';
-- ── 2. Перестройка логического UNIQUE-ключа: ДОБАВляем dev_group_name ───────────
-- Цель ON CONFLICT воркера (#950 Step5 / #970 fix). NULLS NOT DISTINCT сохраняется (PG15+):
-- L1/L2 (dev_group_name=NULL) дедуплятся по остальной части ключа как раньше; L3 с разными
-- непустыми dev_group_name дают разные ключи и больше не схлопываются. Guarded DROP+ADD через
-- pg_constraint → idempotent re-apply (констрейнт нельзя добавить дважды; имя то же —
-- uq_supply_layers_logical, чтобы ON CONFLICT таргетил его по column-list). Паттерн зеркалит
-- м.125 (constraint-add guard) + м.40 (FK guard). DROP с тем же именем безопасен: на «чистой»
-- м.125-таблице он снимает старый 6-колоночный ключ, затем ставит новый 7-колоночный; на
-- повторном прогоне старого имени уже нет → DROP no-op (IF EXISTS), ADD пропускается guard'ом.
DO $$
BEGIN
-- Снять старый ключ (м.125: без dev_group_name), если он есть. IF EXISTS → re-apply safe.
ALTER TABLE supply_layers
DROP CONSTRAINT IF EXISTS uq_supply_layers_logical;
-- Поставить новый ключ (с dev_group_name) только если его ещё нет → re-apply конвергентен.
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_supply_layers_logical'
) THEN
ALTER TABLE supply_layers
ADD CONSTRAINT uq_supply_layers_logical
UNIQUE NULLS NOT DISTINCT
(layer, district_name, complex_id, obj_class,
dev_group_name, source, snapshot_date);
END IF;
END
$$;
COMMIT;