From 45d5a338ea9d2ff9102391e7b806c07db4db972a Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 4 Jun 2026 12:19:21 +0500 Subject: [PATCH] fix(db): auto-derive cad_parcels.quarter_cad_number trigger (on-demand ingest 500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../129_cad_parcels_quarter_cad_trigger.sql | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 data/sql/129_cad_parcels_quarter_cad_trigger.sql diff --git a/data/sql/129_cad_parcels_quarter_cad_trigger.sql b/data/sql/129_cad_parcels_quarter_cad_trigger.sql new file mode 100644 index 00000000..904904c5 --- /dev/null +++ b/data/sql/129_cad_parcels_quarter_cad_trigger.sql @@ -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; -- 2.45.3