fix(db): dedupe domrf_kn_documents + UNIQUE NULLS NOT DISTINCT (#1206)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m33s
CI / backend-tests (pull_request) Successful in 6m31s
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m33s
CI / backend-tests (pull_request) Successful in 6m31s
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
This commit is contained in:
parent
4c2f19ace0
commit
8caa41dcb1
1 changed files with 130 additions and 0 deletions
130
data/sql/140_domrf_documents_unique_nulls_not_distinct.sql
Normal file
130
data/sql/140_domrf_documents_unique_nulls_not_distinct.sql
Normal file
|
|
@ -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: `<table>_<columns>_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'ом по правилу `<table>_<col1>_<col2>_..._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 обновлён.
|
||||
Loading…
Add table
Reference in a new issue