From 8caa41dcb1890dc7c583cdddbe9595e098d4e627 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 11:05:17 +0500 Subject: [PATCH] fix(db): dedupe domrf_kn_documents + UNIQUE NULLS NOT DISTINCT (#1206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UNIQUE (obj_id, doc_type, doc_num, file_url) в м.112 без NULLS NOT DISTINCT, doc_num nullable. В PG NULL != NULL в уникальном индексе → для документов с docNum=null (документированный частый вход: extract_documents:125-134 → doc_num=None) ON CONFLICT-арбитр в scrapers/documents.py:59 НИКОГДА не матчился → каждый ежедневный sweep domrf_kn.py:1903 молча вставлял новую строку-дубль вместо UPDATE. Таблица росла ×N прогонов; дубли уходили в analytics_queries.py:1068. Миграция 140: 1. Дедуп существующих NULL-дублей: ROW_NUMBER() PARTITION BY все 4 ключа ORDER BY scraped_at DESC, id DESC → оставляем самую свежую, удаляем остальные WHERE rn>1 AND doc_num IS NULL. 2. DROP старого autonamed constraint'а IF EXISTS. 3. ADD uq_domrf_kn_documents_logical UNIQUE NULLS NOT DISTINCT (...) с именованным guard по pg_constraint.conname (паттерн м.125 → idempotent). Caller scrapers/documents.py НЕ меняется — после миграции ON CONFLICT- арбитр начнёт матчить NULL-doc_num строки автоматически. Прецедент того же класса: м.110 (uq_infra_dedupe), м.125 (uq_supply_layers_logical). Closes #1206 --- ...rf_documents_unique_nulls_not_distinct.sql | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 data/sql/140_domrf_documents_unique_nulls_not_distinct.sql diff --git a/data/sql/140_domrf_documents_unique_nulls_not_distinct.sql b/data/sql/140_domrf_documents_unique_nulls_not_distinct.sql new file mode 100644 index 00000000..21486b59 --- /dev/null +++ b/data/sql/140_domrf_documents_unique_nulls_not_distinct.sql @@ -0,0 +1,130 @@ +-- 140_domrf_documents_unique_nulls_not_distinct.sql +-- Issue #1206 (P2 data-integrity). Fix UNIQUE-key NULL-handling в domrf_kn_documents. +-- +-- Bug: +-- М.112 объявила inline UNIQUE (obj_id, doc_type, doc_num, file_url) БЕЗ NULLS NOT +-- DISTINCT. doc_num — nullable, и scrapers/documents.py:135 реально передаёт +-- doc_num=None для документов без номера (DOM.РФ docNum=null — нередкий кейс). +-- В Postgres по умолчанию `NULL <> NULL` в уникальном индексе → ON CONFLICT +-- арбитр в scrapers/documents.py:59 НЕ матчит существующую строку с doc_num IS NULL +-- → каждый daily sweep (domrf_kn.py:1903) молча INSERT-ит дубль вместо UPDATE. +-- Таблица растёт ×N прогонов, дубли уходят в API analytics_queries.py:1068. +-- +-- Fix (этот файл): +-- 1. Дедуп существующих дублей с doc_num IS NULL — keep самый свежий по scraped_at +-- (с tiebreak ↑id, чтобы получить детерминированный winner). +-- 2. DROP старого UNIQUE-constraint'а (autonamed Postgres'ом из inline UNIQUE +-- в м.112: `__key` → 55 chars, fits 63-char limit, без truncation). +-- 3. ADD нового UNIQUE NULLS NOT DISTINCT с теми же столбцами — NULL==NULL +-- в матчинге, ON CONFLICT начинает попадать в существующую строку. +-- +-- Why NULLS NOT DISTINCT, не doc_num NOT NULL DEFAULT '': +-- Issue #1206 предлагал две альтернативы. NULLS NOT DISTINCT — чище: ни строчки +-- data-coercion (пустая строка ≠ "не было номера" семантически), не трогаем +-- scrapers/documents.py (caller остаётся корректным как есть). Прецедент в репо: +-- м.110 (uq_infra_dedupe), м.125 (uq_supply_layers_logical) — те же грабли, +-- тот же fix. +-- +-- Caller (НЕ меняется этим PR-ом): +-- backend/app/services/scrapers/documents.py:59 — ON CONFLICT (obj_id, doc_type, +-- doc_num, file_url) DO UPDATE … уже корректен по столбцам. После этой миграции +-- арбитр начнёт матчить строки с NULL doc_num — UPDATE вместо INSERT — дубли +-- перестают копиться. Никаких изменений в documents.py не требуется. +-- +-- Dependencies: +-- • PostgreSQL 16 (postgis/postgis:16-3.4) — NULLS NOT DISTINCT доступен с PG15. +-- • Никакие views/MVs не ссылаются на domrf_kn_documents по имени constraint'а +-- (grep по data/sql/ — пусто). Caller ссылается ТОЛЬКО на column list в ON +-- CONFLICT, имя constraint'а не упомянуто нигде. +-- +-- Deploy order: +-- 1. Эта SQL-миграция применяется первой (auto-apply через deploy.yml + +-- _schema_migrations tracking по имени файла, ровно один раз). +-- 2. Backend code НЕ меняется — re-deploy не обязателен под этот PR. +-- +-- Idempotency: +-- • DELETE — конвергентный (после первого прогона дубли нет, повторный запуск +-- удаляет 0 строк). +-- • DROP CONSTRAINT IF EXISTS — re-apply safe. +-- • ADD CONSTRAINT в guarded DO-блок по pg_constraint.conname (constraint нельзя +-- добавить дважды; прецедент паттерна — м.125 uq_supply_layers_logical, м.40 FK). +-- • Re-apply дважды чисто, без ошибок. + +BEGIN; + +-- ── 1. Дедуп существующих строк с doc_num IS NULL ───────────────────────────── +-- Группируем по (obj_id, doc_type, doc_num, file_url). Оставляем самую СВЕЖУЮ +-- строку по scraped_at — это семантически правильный winner: scraped_at бампится +-- на каждом UPDATE в caller'е (documents.py:63 `scraped_at = NOW()`), значит +-- держим версию с самыми свежими метаданными (size_bytes, local_path, +-- downloaded_at). Tiebreak по id DESC — детерминированный winner при равных +-- scraped_at (массовый backfill в одной транзакции). +-- +-- DELETE-by-id (не ctid) безопасен на партицируемых таблицах и при VACUUM FULL. +-- Только doc_num IS NULL — дубли с не-NULL doc_num невозможны (исходный UNIQUE +-- их корректно ловил, NULL != NULL — единственная дыра). +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY obj_id, doc_type, doc_num, file_url + ORDER BY scraped_at DESC, id DESC + ) AS rn + FROM domrf_kn_documents + WHERE doc_num IS NULL +) +DELETE FROM domrf_kn_documents +WHERE id IN (SELECT id FROM ranked WHERE rn > 1); + +-- ── 2. Сбросить старый UNIQUE (inline-объявление в м.112) ───────────────────── +-- Имя autonamed Postgres'ом по правилу `
___..._key`. Для +-- domrf_kn_documents(obj_id, doc_type, doc_num, file_url) это +-- `domrf_kn_documents_obj_id_doc_type_doc_num_file_url_key` — 55 chars, fits +-- 63-char identifier limit, без truncation. IF EXISTS — re-apply safe. +ALTER TABLE domrf_kn_documents + DROP CONSTRAINT IF EXISTS domrf_kn_documents_obj_id_doc_type_doc_num_file_url_key; + +-- ── 3. Поднять новый UNIQUE NULLS NOT DISTINCT ──────────────────────────────── +-- Те же столбцы — column-list в caller'ах (documents.py:59 ON CONFLICT) НЕ +-- меняется. NULLS NOT DISTINCT (PG15+) — NULL'ы сравниваются как равные в +-- уникальном индексе, ON CONFLICT начинает матчить строки с doc_num IS NULL. +-- Constraint именованный (uq_domrf_kn_documents_logical) — стабильное имя для +-- diagnostics и идемпотентности (autoгенерированные имена ломаются при +-- перестановке столбцов). Guard по pg_constraint → re-apply не падает +-- "constraint already exists" (прецедент: м.125 uq_supply_layers_logical). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'uq_domrf_kn_documents_logical' + ) THEN + ALTER TABLE domrf_kn_documents + ADD CONSTRAINT uq_domrf_kn_documents_logical + UNIQUE NULLS NOT DISTINCT (obj_id, doc_type, doc_num, file_url); + END IF; +END +$$; + +COMMENT ON CONSTRAINT uq_domrf_kn_documents_logical ON domrf_kn_documents IS + 'Логический dedup-ключ. NULLS NOT DISTINCT — NULL doc_num сравнивается как ' + 'равное (PG15+). Цель ON CONFLICT для scrapers/documents.py:59. Issue #1206.'; + +COMMIT; + +-- ── Smoke (псевдо-тест для будущего dev'а; НЕ выполняется автоматически) ────── +-- После применения этой миграции `\d+ domrf_kn_documents` в psql должно показать: +-- Indexes: +-- "domrf_kn_documents_pkey" PRIMARY KEY, btree (id) +-- "uq_domrf_kn_documents_logical" UNIQUE CONSTRAINT, btree (obj_id, doc_type, +-- doc_num, file_url) NULLS NOT DISTINCT +-- "domrf_kn_documents_obj_type_idx" btree (obj_id, doc_type) +-- +-- Ручная sanity-проверка: +-- INSERT INTO domrf_kn_documents (obj_id, doc_type, doc_num, file_url) +-- VALUES (1, 'прочее', NULL, 'https://x.test/a.pdf'); +-- INSERT INTO domrf_kn_documents (obj_id, doc_type, doc_num, file_url) +-- VALUES (1, 'прочее', NULL, 'https://x.test/a.pdf') +-- ON CONFLICT (obj_id, doc_type, doc_num, file_url) DO UPDATE +-- SET scraped_at = NOW(); +-- -- → 1 строка в таблице (вместо 2 до фикса), scraped_at обновлён. -- 2.45.3