gendesign/data/sql/129_cad_parcels_quarter_cad_trigger.sql
Light1YT 45d5a338ea
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 29s
Deploy / build-worker (push) Successful in 29s
Deploy / deploy (push) Successful in 58s
fix(db): auto-derive cad_parcels.quarter_cad_number trigger (on-demand ingest 500)
PROD bug: on-demand НСПД ingest (bulk_harvest.upsert_parcel, for parcels NOT
pre-loaded) intermittently inserts quarter_cad_number=NULL at runtime →
NotNullViolation → geometry never persists → POST /analyze for a real new parcel
stuck "Геометрия загружается из НСПД…" forever (re-enqueues each call). Repro:
66:41:0610029:83 (real НСПД parcel; failing row had valid geom + props, only
quarter_cad_number null). Code derives quarter_cad but NULL still reaches the
INSERT via some runtime path (grid-walk).

Migration 129: BEFORE INSERT OR UPDATE trigger on cad_parcels — when
quarter_cad_number IS NULL and cad_num has >=3 colon-segments, derive it
(first 3 segments: 66:41:0610029:83 → 66:41:0610029). Fixes ALL write paths at
the DB level (quarter_cad_number is a pure derivation of cad_num). <3-segment
cad_num intentionally left NULL → NOT NULL still rejects genuine garbage (no
masking). Coexists with m.92 set_updated_at trigger. Idempotent.

Dry-run-verified on prod catalog: null-quarter insert → derived 66:41:0610029;
garbage <3seg still rejected. Verification = re-fetch 0610029:83 post-deploy.
2026-06-04 12:19:21 +05:00

120 lines
11 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.

-- 129_cad_parcels_quarter_cad_trigger.sql
-- FIX (prod, CRITICAL) — on-demand НСПД-ингест роняет POST /analyze для «нового» участка:
-- NotNullViolation на cad_parcels.quarter_cad_number → геометрия не сохраняется навсегда.
--
-- БАГ (ground-truth, измерено на prod):
-- cad_parcels.quarter_cad_number — NOT NULL (м.92, строка 53). On-demand-ингест НСПД
-- (backend/app/services/cadastre/bulk_harvest.py :: upsert_parcel, вызывается parcels-fetch
-- воркером для участков, которых НЕТ в предзагруженной БД) ИНОГДА пишет
-- quarter_cad_number = NULL в рантайме →
-- psycopg.errors.NotNullViolation: null value in column "quarter_cad_number"
-- of relation "cad_parcels"
-- → fetch-таргет участка падает → geom НЕ персистится → POST /parcels/{cad}/analyze
-- для этого участка навсегда залипает в «Геометрия загружается из НСПД…» и пере-ставит
-- задачу при каждом вызове. Ломает базовый сценарий «пользователь вводит новый/произвольный
-- РЕАЛЬНЫЙ кадастровый номер». Воспроизведено на РЕАЛЬНОМ НСПД-участке 66:41:0610029:83
-- (упавшая строка несёт валидный geom-blob + NSPD raw_props; null только quarter_cad_number).
--
-- Код ПЫТАЕТСЯ вывести квартал сам:
-- quarter_cad = opts.quarter_cad_number or _derive_quarter_cad(cad_num) (bulk_harvest.py:462)
-- и _derive_quarter_cad (там же :1288) даёт '66:41:0610029:83' → '66:41:0610029'.
-- Но null всё равно доезжает до INSERT в рантайме (вероятно grid-walk / отдельный feature-путь,
-- где quarter попадает в параметр уже как None в обход этой ветки). Вместо охоты за каждым
-- код-путём чиним на уровне БД: quarter_cad_number — ЧИСТАЯ производная от cad_num (первые
-- 3 colon-сегмента), поэтому пусть БД её и гарантирует — для ВСЕХ путей записи разом.
--
-- ПОЧЕМУ ТРИГГЕР (а не CHECK / GENERATED / правка кода):
-- • Производное поле → его можно детерминированно достроить из cad_num прямо на write.
-- • BEFORE INSERT OR UPDATE FOR EACH ROW чинит ВСЕ пути записи разом: on-demand fetch (тот
-- самый неуловимый рантайм-путь), upsert_parcel/bulk_harvest, bulk-загрузчики, ручной SQL —
-- никому не нужно знать про вывод квартала. Один инвариант в БД вместо N мест в коде.
-- • GENERATED ALWAYS AS не подходит: (а) колонка уже существует как обычная NOT NULL
-- (нельзя «доконвертить» в generated через ALTER без пересоздания), (б) generated-колонку
-- нельзя писать явно, а ON CONFLICT DO UPDATE воркера (bulk_harvest.py:500) присваивает
-- quarter_cad_number = EXCLUDED.quarter_cad_number — это бы сломалось. Триггер же
-- прозрачно достраивает NULL и не мешает явным не-NULL значениям.
--
-- ПОВЕДЕНИЕ ТРИГГЕРА (ровно один инвариант):
-- Если NEW.quarter_cad_number IS NULL И NEW.cad_num IS NOT NULL И cad_num содержит ≥3
-- colon-сегмента → NEW.quarter_cad_number := первые 3 сегмента через ':'
-- array_to_string((string_to_array(NEW.cad_num, ':'))[1:3], ':')
-- Проверено на prod: '66:41:0610029:83' → '66:41:0610029'. Зеркалит Python-семантику
-- _derive_quarter_cad (split(':'), len≥3 → ':'.join(parts[:3])).
--
-- БЕЗОПАСНОСТЬ <3 сегментов (НАМЕРЕННОЕ расхождение с Python, документируется):
-- Если cad_num содержит <3 сегментов (валидный квартал из него НЕ собрать) → НЕ трогаем
-- NEW.quarter_cad_number (остаётся NULL) → NOT NULL-констрейнт по-прежнему ОТКЛОНЯЕТ такую
-- строку. Это СОЗНАТЕЛЬНО строже Python-ветки (_derive_quarter_cad на <3 возвращает cad_num
-- КАК ЕСТЬ, т.е. протащил бы мусор как «квартал»): не маскируем реально-битые кадастровые
-- номера фейковым кварталом, пусть падают громко. Корректные НСПД-участки ВСЕГДА имеют ≥3
-- сегмента (РР:ОО:КККККК[:УУ]), поэтому легитимный on-demand-ингест НИКОГДА в эту ветку не
-- попадает — он всегда получает выведенный квартал.
--
-- BACKFILL НЕ нужен: quarter_cad_number — NOT NULL с м.92, поэтому существующих null-строк НЕТ
-- (БД бы их не приняла). Триггер закрывает ВСЕ будущие INSERT/UPDATE из любого код-пути.
--
-- Сосуществование с существующим триггером cad_parcels_set_updated_at (м.92, BEFORE UPDATE,
-- set_updated_at()): два BEFORE ROW-триггера на одной таблице легальны; PG вызывает их в
-- АЛФАВИТНОМ порядке по имени. Они независимы (один достраивает quarter_cad_number, другой
-- ставит updated_at = NOW()) → порядок не важен, конфликта нет. Имя нового — отдельное
-- (cad_parcels_derive_quarter_cad), существующий триггер не трогаем.
--
-- ПРОИЗВОДИТЕЛЬНОСТЬ: тривиально — на строку лишь string_to_array/срез/array_to_string по
-- короткой строке, только когда quarter_cad_number IS NULL (для уже-заполненных значений —
-- ранний выход по условию, цена ~ноль). Пренебрежимо на любом объёме ингеста/bulk-загрузки.
--
-- Idempotent (acceptance — ре-applied чисто ДВАЖДЫ): CREATE OR REPLACE FUNCTION (переопределяет
-- на месте) + DROP TRIGGER IF EXISTS перед CREATE TRIGGER (PG16 не имеет CREATE OR REPLACE
-- TRIGGER → паттерн DROP-IF-EXISTS+CREATE, как в м.90). BEGIN/COMMIT — атомарно. НИКАКИХ
-- DROP TABLE/TRUNCATE, никаких INSERT/строк.
--
-- Deploy order: чистая СХЕМА, БЕЗ зависимости от кода — безопасно катить в любой момент
-- (auto-applied by deploy.yml через _schema_migrations tracking, ровно один раз по NN).
-- Эффект мгновенный: со следующего же on-demand-ингеста NULL-quarter достраивается в БД,
-- NotNullViolation на легитимном НСПД-участке становится НЕВОЗМОЖНОЙ. Правки backend НЕ
-- требуется (существующий or _derive_quarter_cad остаётся как есть и не конфликтует).
BEGIN;
-- ── 1. Триггер-функция: достроить quarter_cad_number из cad_num (если NULL) ─────────
-- Зеркалит _derive_quarter_cad (bulk_harvest.py:1288), НО на <3 сегментов оставляет NULL
-- (см. «БЕЗОПАСНОСТЬ <3 сегментов» в шапке) — чтобы NOT NULL отсекал реально-битые cad_num.
-- CREATE OR REPLACE → re-apply переопределяет тело на месте (idempotent).
CREATE OR REPLACE FUNCTION cad_parcels_derive_quarter_cad() RETURNS TRIGGER AS $$
BEGIN
-- Достраиваем ТОЛЬКО когда квартал не задан, cad_num есть и из него собирается
-- валидный квартал (≥3 colon-сегмента: РР:ОО:КККККК[...]). Иначе NEW не трогаем:
-- • quarter уже задан → уважаем явное значение (в т.ч. ON CONFLICT EXCLUDED.*);
-- • cad_num NULL / <3 сегментов → оставляем quarter_cad_number = NULL, пусть NOT NULL
-- отклонит строку (не маскируем мусор фейковым кварталом).
IF NEW.quarter_cad_number IS NULL
AND NEW.cad_num IS NOT NULL
AND array_length(string_to_array(NEW.cad_num, ':'), 1) >= 3
THEN
-- '66:41:0610029:83' → '66:41:0610029' (первые 3 сегмента; проверено на prod).
NEW.quarter_cad_number :=
array_to_string((string_to_array(NEW.cad_num, ':'))[1:3], ':');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION cad_parcels_derive_quarter_cad() IS
'BEFORE INSERT OR UPDATE-триггер cad_parcels (#fix on-demand-ингест): достраивает '
'NOT NULL quarter_cad_number из первых 3 colon-сегментов cad_num, когда он NULL '
'(пример: 66:41:0610029:83 → 66:41:0610029). Чинит NotNullViolation во ВСЕХ путях '
'записи (on-demand fetch / bulk_harvest / bulk-загрузчики). На cad_num с <3 сегментов '
'НАМЕРЕННО оставляет NULL → NOT NULL отклоняет реально-битые номера (не маскирует мусор).';
-- ── 2. Триггер BEFORE INSERT OR UPDATE на cad_parcels ──────────────────────────────
-- INSERT — основной путь бага (on-demand-ингест роняет именно INSERT). UPDATE — тоже под
-- защитой: ON CONFLICT DO UPDATE воркера присваивает quarter_cad_number = EXCLUDED.* (мог бы
-- быть NULL при ре-ингесте). PG16 не имеет CREATE OR REPLACE TRIGGER → DROP IF EXISTS + CREATE
-- (idempotent; паттерн м.90). Существующий cad_parcels_set_updated_at (м.92) не трогаем —
-- независимый BEFORE-триггер, сосуществует (см. шапку).
DROP TRIGGER IF EXISTS cad_parcels_derive_quarter_cad ON cad_parcels;
CREATE TRIGGER cad_parcels_derive_quarter_cad
BEFORE INSERT OR UPDATE ON cad_parcels
FOR EACH ROW EXECUTE FUNCTION cad_parcels_derive_quarter_cad();
COMMIT;