fix(db): auto-derive quarter_cad_number trigger (on-demand ingest 500) #1042
1 changed files with 120 additions and 0 deletions
120
data/sql/129_cad_parcels_quarter_cad_trigger.sql
Normal file
120
data/sql/129_cad_parcels_quarter_cad_trigger.sql
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
-- 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;
|
||||
Loading…
Add table
Reference in a new issue