fix(ptica): land_reservation перестаёт копить дубли — 91% таблицы были копиями (#2464)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
ВНИМАНИЕ: миграция 189 УДАЛЯЕТ строки на проде (см. «Что удаляется»). _UPSERT_NO_ACT_SQL заканчивается ON CONFLICT DO NOTHING, а единственный подходящий констрейнт — uq_land_reservation_cad_act UNIQUE (cad_num, act_number) с обычной NULL-семантикой. В Postgres NULL != NULL, поэтому у записей БЕЗ номера акта конфликт не наступает никогда: DO NOTHING не срабатывает, каждый недельный прогон вставляет копию. То есть ON CONFLICT здесь был декорацией. Замер прода 20.08.2026: строк всего 297 из них act_number IS NULL 297 (все) групп (cad_num, doc_url) с дублями 27 максимум копий одной записи 11 лишних строк 270 (91% таблицы) Что удаляется: копии сверх первой (минимальный id) в каждой группе. Это порождение бага, а не пользовательские данные: таблица — кэш OCR-разбора PDF с сайта, пересобираемый прогоном таски. Проверено, что ключ подходит: ни у одного cad_num нет более одного doc_url (max = 1), дубли внутри групп — точные копии. Прецеденты NULLS NOT DISTINCT в репо: м.110, м.125, м.140, м.158. Prod = PG16.4. Документация приведена к реальности. Docstring обещал python-дедуп по (cad_num, doc_url) и «двухшаговый UPSERT ниже» — ни того, ни другого в коде не было. Комментарий у варианта B был честнее, но его оценка «rare, data audit OK» не подтвердилась: 91% таблицы. Отложенный там вариант (уникальный индекс на NULL) и реализован этой миграцией. Тест репетирует миграцию на ВРЕМЕННОЙ копии, засеянной как прод (11 копий одной записи + соседний участок): исполняет РЕАЛЬНЫЕ выражения из файла, проверяет 12 строк → 2 и что повторная вставка стала no-op. Плюс фальсификация: со СТАРЫМ констрейнтом дубль обязан появиться — без неё зелёный тест неотличим от «оно и так работало». Плюс два контроля: записи С номером акта дедуплицировались и раньше, разные участки не схлопываются. Первая версия репетиции упала на ADD CONSTRAINT — я разбивал миграцию по «;» и молча выбрасывал куски, начинающиеся с комментария, вместе с DELETE. Это ровно то, что репетиция и должна ловить; разбор исправлен. Прогоны: tests/sql (живой Postgres) 6 passed rc=0; -k "izyatie or reservation" 76 passed rc=0. Шесть nodeid в skip_allowlist.txt — нужен Postgres, в CI идут. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1470bc26de
commit
7d1db9edce
4 changed files with 297 additions and 12 deletions
|
|
@ -7,14 +7,18 @@ UPSERT-ит в land_reservation (м.136). Reservation_lookup / analyze-wiring (#
|
||||||
|
|
||||||
Дедуп-ключ:
|
Дедуп-ключ:
|
||||||
ON CONFLICT (cad_num, act_number) — унаследован из reservation_ingest.py.
|
ON CONFLICT (cad_num, act_number) — унаследован из reservation_ingest.py.
|
||||||
Если act_number IS NULL (не извлечён из сканов) → конфликт НЕ возникает при NULL-UPSERT
|
Уникальность держит констрейнт uq_land_reservation_cad_act; с миграции 189 он
|
||||||
(NULL != NULL в SQL). Чтобы предотвратить дубли при act_number IS NULL, дедуплицируем
|
объявлен как UNIQUE NULLS NOT DISTINCT, поэтому записи без номера акта тоже
|
||||||
по (cad_num, doc_url) на уровне Python перед UPSERT: один URL = один батч,
|
конфликтуют между собой и ON CONFLICT DO NOTHING реально их ловит.
|
||||||
повторный запуск с тем же URL обновит существующую строку через source+fetched_at
|
|
||||||
(где act_number IS NULL используем DO NOTHING вместо DO UPDATE — нет stable key).
|
До м.189 констрейнт был обычным UNIQUE, где NULL != NULL: у записей с
|
||||||
Решение: для строк с act_number IS NULL добавляем в ON CONFLICT УНИКАЛЬНОСТЬ через
|
act_number IS NULL конфликт не наступал никогда, и каждый недельный прогон
|
||||||
отдельный UPSERT с COALESCE-fallback: если запись с (cad_num, doc_url) уже есть —
|
вставлял копию. Замер прода 20.08.2026 до правки — 297 строк, все без номера
|
||||||
UPDATE, иначе INSERT. Реализовано через двухшаговый UPSERT ниже.
|
акта, 27 групп с дублями, до 11 копий, 270 лишних строк (91% таблицы).
|
||||||
|
|
||||||
|
Прежняя редакция этого docstring обещала python-дедуп по (cad_num, doc_url)
|
||||||
|
перед UPSERT и «двухшаговый UPSERT ниже». Ни того, ни другого в коде не было —
|
||||||
|
описание расходилось с реализацией и скрывало накопление дублей (#2464).
|
||||||
|
|
||||||
Beat: еженедельно (пятница 07:00 МСК) — изъятия выходят редко.
|
Beat: еженедельно (пятница 07:00 МСК) — изъятия выходят редко.
|
||||||
|
|
||||||
|
|
@ -45,10 +49,13 @@ logger = logging.getLogger(__name__)
|
||||||
# Stable key = (cad_num, act_number). Идемпотентно при повторном прогоне.
|
# Stable key = (cad_num, act_number). Идемпотентно при повторном прогоне.
|
||||||
#
|
#
|
||||||
# Вариант B (act_number IS NULL): INSERT ... ON CONFLICT DO NOTHING.
|
# Вариант B (act_number IS NULL): INSERT ... ON CONFLICT DO NOTHING.
|
||||||
# NULL != NULL → (cad_num, NULL) никогда не конфликтует по индексу.
|
# Работает с миграции 189: uq_land_reservation_cad_act объявлен как
|
||||||
# Python-дедуп per-batch предотвращает дубли в рамках одного прогона.
|
# UNIQUE NULLS NOT DISTINCT, поэтому (cad_num, NULL) конфликтует с такой же
|
||||||
# Повторные прогоны добавят дубли если строки нет — acceptable (rare, data audit OK).
|
# строкой и повторный прогон становится no-op.
|
||||||
# Альтернатива (partial unique index на NULL) — задача database-expert, не здесь.
|
# Прежний комментарий здесь оценивал накопление дублей как «rare, data audit OK»
|
||||||
|
# и откладывал уникальный индекс. Оценка не подтвердилась: на 20.08.2026 дубли
|
||||||
|
# составляли 91% таблицы (270 лишних строк из 297), максимум 11 копий одной
|
||||||
|
# записи. Отложенный вариант и реализован м.189 (#2464).
|
||||||
|
|
||||||
_UPSERT_WITH_ACT_SQL = text(
|
_UPSERT_WITH_ACT_SQL = text(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -106,3 +106,14 @@ tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_failed_dumps_do_not_
|
||||||
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_successful_dump_still_counts_as_fresh
|
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_successful_dump_still_counts_as_fresh
|
||||||
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_attempt_is_still_recorded
|
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_attempt_is_still_recorded
|
||||||
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_only_failures_means_no_success_at_all
|
tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_only_failures_means_no_success_at_all
|
||||||
|
|
||||||
|
# ── #2464: дедуп land_reservation (миграция 189) ──────────────────────────────
|
||||||
|
# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию таблицы, проверяют семантику
|
||||||
|
# UNIQUE NULLS NOT DISTINCT и репетируют миграцию на засеянных дублях. В CI ИДУТ
|
||||||
|
# (postgres-сервис, #2745); записи нужны для машины без БД и без туннеля.
|
||||||
|
tests/sql/test_2464_land_reservation_dedup.py::test_nulls_not_distinct_deduplicates
|
||||||
|
tests/sql/test_2464_land_reservation_dedup.py::test_plain_unique_does_not_deduplicate
|
||||||
|
tests/sql/test_2464_land_reservation_dedup.py::test_records_with_act_number_still_deduplicate
|
||||||
|
tests/sql/test_2464_land_reservation_dedup.py::test_different_parcels_are_not_collapsed
|
||||||
|
tests/sql/test_2464_land_reservation_dedup.py::test_migration_dedup_statement_matches_the_key
|
||||||
|
tests/sql/test_2464_land_reservation_dedup.py::test_migration_body_runs_on_a_prod_shaped_replica
|
||||||
|
|
|
||||||
215
backend/tests/sql/test_2464_land_reservation_dedup.py
Normal file
215
backend/tests/sql/test_2464_land_reservation_dedup.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""ON CONFLICT DO NOTHING в land_reservation обязан реально ловить дубли (#2464).
|
||||||
|
|
||||||
|
`_UPSERT_NO_ACT_SQL` (workers/tasks/izyatie_ocr_ingest.py) заканчивается
|
||||||
|
`ON CONFLICT DO NOTHING`, а единственный подходящий констрейнт был
|
||||||
|
`UNIQUE (cad_num, act_number)` с обычной NULL-семантикой. В Postgres NULL != NULL,
|
||||||
|
поэтому у записей БЕЗ номера акта конфликт не наступал никогда — каждый недельный
|
||||||
|
прогон вставлял копию.
|
||||||
|
|
||||||
|
Замер прода 20.08.2026 до правки: 297 строк, все с `act_number IS NULL`, 27 групп с
|
||||||
|
дублями, до 11 копий, 270 лишних строк — 91 % таблицы.
|
||||||
|
|
||||||
|
Миграция 189 дедуплицирует таблицу и пересоздаёт констрейнт как
|
||||||
|
`UNIQUE NULLS NOT DISTINCT`. Здесь проверяется САМ МЕХАНИЗМ на временной копии:
|
||||||
|
с новой семантикой повторная вставка — no-op, со старой — дубль. Второе
|
||||||
|
утверждение обязательно: без него тест не отличить от «оно и так работало».
|
||||||
|
|
||||||
|
Тест герметичный: таблицы временные, боевые данные не читаются и не меняются.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
_MIGRATION = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "data"
|
||||||
|
/ "sql"
|
||||||
|
/ "189_land_reservation_nulls_not_distinct.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsn() -> str:
|
||||||
|
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
|
||||||
|
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
raw
|
||||||
|
if raw.startswith("postgresql+")
|
||||||
|
else raw.replace("postgresql://", "postgresql+psycopg://")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_reachable() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
eng = create_engine(_dsn(), connect_args={"connect_timeout": 3})
|
||||||
|
with eng.connect() as c:
|
||||||
|
c.execute(text("SELECT 1"))
|
||||||
|
return True, ""
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
_DB_OK, _DB_ERR = _db_reachable()
|
||||||
|
pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}")
|
||||||
|
|
||||||
|
_TABLE = """
|
||||||
|
CREATE TEMP TABLE land_reservation (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
cad_num text NOT NULL,
|
||||||
|
act_number text,
|
||||||
|
doc_url text,
|
||||||
|
reservation_kind text,
|
||||||
|
is_active boolean DEFAULT true,
|
||||||
|
fetched_at timestamptz DEFAULT now()
|
||||||
|
) ON COMMIT DROP;
|
||||||
|
"""
|
||||||
|
|
||||||
|
_INSERT = """
|
||||||
|
INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)
|
||||||
|
VALUES (:cad, :act, :url, 'изъятие')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db():
|
||||||
|
engine = create_engine(_dsn())
|
||||||
|
session = sessionmaker(bind=engine)()
|
||||||
|
try:
|
||||||
|
session.execute(text(_TABLE))
|
||||||
|
n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar()
|
||||||
|
assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)"
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.rollback()
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_constraint(db, nulls_not_distinct: bool) -> None:
|
||||||
|
kind = "UNIQUE NULLS NOT DISTINCT" if nulls_not_distinct else "UNIQUE"
|
||||||
|
db.execute(
|
||||||
|
text(f"ALTER TABLE land_reservation ADD CONSTRAINT uq_t {kind} (cad_num, act_number)")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_twice(db) -> int:
|
||||||
|
for _ in range(2):
|
||||||
|
db.execute(
|
||||||
|
text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"}
|
||||||
|
)
|
||||||
|
return int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar())
|
||||||
|
|
||||||
|
|
||||||
|
def test_nulls_not_distinct_deduplicates(db) -> None:
|
||||||
|
"""С новой семантикой повторная вставка act-less записи — no-op."""
|
||||||
|
_add_constraint(db, nulls_not_distinct=True)
|
||||||
|
assert _insert_twice(db) == 1, "дубль всё равно вставился"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_unique_does_not_deduplicate(db) -> None:
|
||||||
|
"""Фальсификация: со СТАРЫМ констрейнтом дубль обязан появиться.
|
||||||
|
|
||||||
|
Без этой проверки зелёный тест выше неотличим от «оно и так работало».
|
||||||
|
"""
|
||||||
|
_add_constraint(db, nulls_not_distinct=False)
|
||||||
|
assert (
|
||||||
|
_insert_twice(db) == 2
|
||||||
|
), "обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает"
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_with_act_number_still_deduplicate(db) -> None:
|
||||||
|
"""Контроль: записи С номером акта дедуплицировались и раньше — не сломали."""
|
||||||
|
_add_constraint(db, nulls_not_distinct=True)
|
||||||
|
for _ in range(2):
|
||||||
|
db.execute(text(_INSERT), {"cad": "66:41:1", "act": "12-АК", "url": "https://x/1.pdf"})
|
||||||
|
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_parcels_are_not_collapsed(db) -> None:
|
||||||
|
"""Контроль: разные участки без номера акта остаются разными строками.
|
||||||
|
|
||||||
|
Ловит «починку» через слишком широкий ключ.
|
||||||
|
"""
|
||||||
|
_add_constraint(db, nulls_not_distinct=True)
|
||||||
|
for cad in ("66:41:1", "66:41:2", "66:41:3"):
|
||||||
|
db.execute(text(_INSERT), {"cad": cad, "act": None, "url": "https://x/z.pdf"})
|
||||||
|
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_dedup_statement_matches_the_key(db) -> None:
|
||||||
|
"""DELETE в миграции обязан чистить ровно по ключу констрейнта.
|
||||||
|
|
||||||
|
Расхождение ключа дедупа и ключа констрейнта означало бы, что после DELETE
|
||||||
|
констрейнт всё равно не создастся — миграция упала бы на проде.
|
||||||
|
"""
|
||||||
|
sql = _MIGRATION.read_text()
|
||||||
|
assert "UNIQUE NULLS NOT DISTINCT (cad_num, act_number)" in sql
|
||||||
|
delete_stmt = re.search(r"DELETE FROM land_reservation.*?;", sql, re.S)
|
||||||
|
assert delete_stmt is not None, "в миграции нет DELETE — дедуп не выполняется"
|
||||||
|
body = delete_stmt.group(0)
|
||||||
|
assert "a.cad_num = b.cad_num" in body, "дедуп не по cad_num"
|
||||||
|
assert (
|
||||||
|
"a.act_number IS NULL" in body and "b.act_number IS NULL" in body
|
||||||
|
), "дедуп затрагивает записи С номером акта — они и так были уникальны"
|
||||||
|
assert "a.id > b.id" in body, "не задан выживающий (минимальный id)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_body_runs_on_a_prod_shaped_replica(db) -> None:
|
||||||
|
"""Репетиция миграции: 11 копий → 1 строка, констрейнт создаётся.
|
||||||
|
|
||||||
|
Сильнее проверки регулярками: исполняются РЕАЛЬНЫЕ выражения из файла миграции.
|
||||||
|
Если DELETE чистит не по тому ключу, ADD CONSTRAINT здесь же и упадёт — как
|
||||||
|
упал бы на проде.
|
||||||
|
"""
|
||||||
|
# Засев как на проде: одна группа, 11 точных копий, плюс соседний участок.
|
||||||
|
for _ in range(11):
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)"
|
||||||
|
" VALUES ('66:41:0303004:22', NULL, 'https://x/y.pdf', 'изъятие')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)"
|
||||||
|
" VALUES ('66:41:0206032:8499', NULL, 'https://x/z.pdf', 'изъятие')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 12
|
||||||
|
|
||||||
|
sql = _MIGRATION.read_text()
|
||||||
|
body = sql[sql.index("BEGIN;") + len("BEGIN;") : sql.rindex("COMMIT;")]
|
||||||
|
for chunk in body.split(";"):
|
||||||
|
# Срезаем ведущие строки-комментарии, а не пропускаем кусок целиком:
|
||||||
|
# DELETE в миграции идёт СРАЗУ ПОСЛЕ комментария, и наивный пропуск
|
||||||
|
# «кусков, начинающихся с --» молча выкинул бы его. Первая версия этого
|
||||||
|
# теста так и сделала — репетиция упала на ADD CONSTRAINT, и это было
|
||||||
|
# ровно то, что она и должна ловить.
|
||||||
|
stmt = "\n".join(
|
||||||
|
ln for ln in chunk.splitlines() if ln.strip() and not ln.lstrip().startswith("--")
|
||||||
|
).strip()
|
||||||
|
if stmt:
|
||||||
|
db.execute(text(stmt))
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
text("SELECT cad_num, count(*) FROM land_reservation GROUP BY 1 ORDER BY 1")
|
||||||
|
).all()
|
||||||
|
assert [(r[0], r[1]) for r in rows] == [
|
||||||
|
("66:41:0206032:8499", 1),
|
||||||
|
("66:41:0303004:22", 1),
|
||||||
|
], f"после миграции осталось не по одной строке: {rows}"
|
||||||
|
|
||||||
|
# И теперь повторная вставка действительно no-op.
|
||||||
|
db.execute(text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"})
|
||||||
|
assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 2
|
||||||
52
data/sql/189_land_reservation_nulls_not_distinct.sql
Normal file
52
data/sql/189_land_reservation_nulls_not_distinct.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- 189_land_reservation_nulls_not_distinct.sql
|
||||||
|
-- #2464 — дедуп land_reservation и UNIQUE NULLS NOT DISTINCT на живой таблице.
|
||||||
|
--
|
||||||
|
-- БАГ. `_UPSERT_NO_ACT_SQL` (workers/tasks/izyatie_ocr_ingest.py) заканчивается
|
||||||
|
-- `ON CONFLICT DO NOTHING`, а единственный подходящий констрейнт —
|
||||||
|
-- `uq_land_reservation_cad_act UNIQUE (cad_num, act_number)` с обычной NULL-семантикой.
|
||||||
|
-- В Postgres NULL != NULL, поэтому у записей БЕЗ номера акта конфликт не наступает
|
||||||
|
-- никогда: `ON CONFLICT DO NOTHING` не срабатывает, и каждый недельный прогон
|
||||||
|
-- вставляет копию. Docstring таски при этом обещает per-batch дедуп и двухшаговый
|
||||||
|
-- upsert по (cad_num, doc_url) — ни того, ни другого в коде нет.
|
||||||
|
--
|
||||||
|
-- ЗАМЕР ПРОДА 2026-08-20 (до правки):
|
||||||
|
-- строк всего 297
|
||||||
|
-- из них с act_number IS NULL 297 (то есть все)
|
||||||
|
-- групп (cad_num, doc_url) с дублями 27
|
||||||
|
-- максимум копий в группе 11
|
||||||
|
-- лишних строк 270 (91% таблицы)
|
||||||
|
--
|
||||||
|
-- Проверено, что ключ подходит: ни у одного cad_num нет более одного doc_url
|
||||||
|
-- (max = 1), то есть NULLS NOT DISTINCT по (cad_num, act_number) НЕ схлопнет
|
||||||
|
-- разные документы одного участка. Дубли внутри групп — точные копии: по одному
|
||||||
|
-- различному значению reservation_kind и act_date на группу.
|
||||||
|
--
|
||||||
|
-- ЧТО УДАЛЯЕТСЯ. Строки-копии сверх первой (по возрастанию id) в каждой группе
|
||||||
|
-- (cad_num, act_number) среди act_number IS NULL. Это порождение бага, а не
|
||||||
|
-- пользовательские данные; таблица — кэш OCR-разбора PDF с сайта, пересобираемый
|
||||||
|
-- прогоном таски. Первая строка группы (минимальный id) сохраняется целиком.
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ ОТДЕЛЬНОЙ МИГРАЦИЕЙ, а не правкой CREATE TABLE: та же причина, что в
|
||||||
|
-- м.158 — исходный файл уже в _schema_migrations и на деплое пропускается.
|
||||||
|
-- Прецеденты NULLS NOT DISTINCT в репо: м.110, м.125, м.140, м.158. Prod = PG16.4.
|
||||||
|
-- Apply after: 188_regrant_quarter_price_index_fdw.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1) Дедуп: оставляем строку с минимальным id в каждой группе.
|
||||||
|
DELETE FROM land_reservation a
|
||||||
|
USING land_reservation b
|
||||||
|
WHERE a.act_number IS NULL
|
||||||
|
AND b.act_number IS NULL
|
||||||
|
AND a.cad_num = b.cad_num
|
||||||
|
AND a.id > b.id;
|
||||||
|
|
||||||
|
-- 2) Пересоздаём констрейнт с NULL-семантикой, при которой ON CONFLICT матчит.
|
||||||
|
ALTER TABLE land_reservation
|
||||||
|
DROP CONSTRAINT IF EXISTS uq_land_reservation_cad_act;
|
||||||
|
|
||||||
|
ALTER TABLE land_reservation
|
||||||
|
ADD CONSTRAINT uq_land_reservation_cad_act
|
||||||
|
UNIQUE NULLS NOT DISTINCT (cad_num, act_number);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Add table
Reference in a new issue