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
99 lines
8.4 KiB
PL/PgSQL
99 lines
8.4 KiB
PL/PgSQL
-- 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;
|