fix(ptica): land_reservation перестаёт копить дубли — 91% таблицы были копиями (#2464) #2966

Merged
bot-backend merged 2 commits from fix/2464-land-reservation-dedup into main 2026-08-20 10:16:35 +00:00
Collaborator

Пункт эпика #2464: izyatie_ocr_ingest.py:101. Оказался не про документацию.

Внимание: миграция 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 по (cad_num, act_number) не схлопнет разные документы одного участка;
  • дубли внутри групп — точные копии: по одному различному reservation_kind и act_date на группу.

Прецеденты NULLS NOT DISTINCT в репо: м.110, м.125, м.140, м.158. Prod = PG 16.4.

Документация приведена к реальности

Docstring обещал python-дедуп по (cad_num, doc_url) перед UPSERT и «двухшаговый UPSERT ниже». Ни того, ни другого в коде не было.

Комментарий у варианта B был честнее — он признавал проблему, но оценивал её как «rare, data audit OK» и откладывал уникальный индекс «database-expert'у». Оценка не подтвердилась: 91 % таблицы. Отложенный там вариант этой миграцией и реализован.

Тест — репетиция, а не описание

На временной копии, засеянной как прод (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 идут.

Пункт эпика #2464: `izyatie_ocr_ingest.py:101`. Оказался не про документацию. > **Внимание:** миграция 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` по `(cad_num, act_number)` не схлопнет разные документы одного участка; - дубли внутри групп — точные копии: по одному различному `reservation_kind` и `act_date` на группу. Прецеденты `NULLS NOT DISTINCT` в репо: м.110, м.125, м.140, м.158. Prod = PG 16.4. ## Документация приведена к реальности Docstring обещал python-дедуп по `(cad_num, doc_url)` перед UPSERT и «двухшаговый UPSERT ниже». **Ни того, ни другого в коде не было.** Комментарий у варианта B был честнее — он признавал проблему, но оценивал её как «rare, data audit OK» и откладывал уникальный индекс «database-expert'у». Оценка не подтвердилась: 91 % таблицы. Отложенный там вариант этой миграцией и реализован. ## Тест — репетиция, а не описание На **временной** копии, засеянной как прод (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 идут.
bot-backend added 1 commit 2026-08-20 09:43:51 +00:00
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
7d1db9edce
ВНИМАНИЕ: миграция 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>
Light1YT force-pushed fix/2464-land-reservation-dedup from 7d1db9edce to 0792e34172 2026-08-20 09:47:45 +00:00 Compare
Light1YT added 1 commit 2026-08-20 09:56:42 +00:00
fix(ptica): lock_timeout в миграции 189 + разбор SQL в репетиции (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
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 / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 17m21s
03926a5d0b
Гейт репозитория поймал упущение: блокирующий DDL без lock_timeout (#2752).
Без него ALTER TABLE встаёт в очередь за чужой сессией и уводит за собой запросы
приложения. Добавлен `SET LOCAL lock_timeout = '5s'` первой строкой после BEGIN.
Пять секунд — про ОЖИДАНИЕ блокировки, не про работу: таблица 297 строк, сам DDL
мгновенный. Не дождались — миграция падает, а не подвешивает прод.

Заодно переписан разбор миграции в тесте-репетиции. Он дважды подвёл на мне же:

  1) пропускал куски, начинающиеся с «--» → терялся DELETE, репетиция падала
     на ADD CONSTRAINT;
  2) резал SQL по «;», встреченной ВНУТРИ текста комментария.

Теперь комментарии снимаются ДО разбиения на выражения — оба класса закрыты.

Прогон: tests/sql/test_2464_land_reservation_dedup.py — 6 passed rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

Путь восстановления — до мержа, не после

Миграция удаляет 270 строк на проде, поэтому фиксирую здесь, чем это откатывается.

Ежедневные дампы БД на VPS (ops/backup.sh, cron 03:30 UTC):

gendesign_20260817_003001.sql.gz   1.4G
gendesign_20260818_003001.sql.gz   1.4G
gendesign_20260819_003001.sql.gz   1.4G
gendesign_20260820_003001.sql.gz   1.4G   ← сегодняшний, снят ДО миграции

Дамп за 20.08 сделан в 03:41, миграция применится позже — то есть в нём таблица в состоянии «до». Восстановление одной таблицы из такого дампа обычной выборкой по land_reservation, полный откат БД не требуется.

Дополнительно: таблица — кэш OCR-разбора PDF с сайта екатеринбург.рф, пересобираемый прогоном таски ingest_izyatie_ocr. То есть даже без дампа данные восстановимы источником.

Что удаляется, ещё раз коротко

270 строк-копий из 297; каждая — точная копия соседа по группе (cad_num, act_number IS NULL). Первая строка группы (минимальный id) сохраняется. Проверено, что ни у одного cad_num нет более одного doc_url, то есть ключ не схлопывает разные документы.

## Путь восстановления — до мержа, не после Миграция удаляет 270 строк на проде, поэтому фиксирую здесь, чем это откатывается. Ежедневные дампы БД на VPS (`ops/backup.sh`, cron 03:30 UTC): ``` gendesign_20260817_003001.sql.gz 1.4G gendesign_20260818_003001.sql.gz 1.4G gendesign_20260819_003001.sql.gz 1.4G gendesign_20260820_003001.sql.gz 1.4G ← сегодняшний, снят ДО миграции ``` Дамп за 20.08 сделан в 03:41, миграция применится позже — то есть в нём таблица в состоянии «до». Восстановление одной таблицы из такого дампа обычной выборкой по `land_reservation`, полный откат БД не требуется. Дополнительно: таблица — кэш OCR-разбора PDF с сайта екатеринбург.рф, пересобираемый прогоном таски `ingest_izyatie_ocr`. То есть даже без дампа данные восстановимы источником. ## Что удаляется, ещё раз коротко 270 строк-копий из 297; каждая — точная копия соседа по группе `(cad_num, act_number IS NULL)`. Первая строка группы (минимальный `id`) сохраняется. Проверено, что ни у одного `cad_num` нет более одного `doc_url`, то есть ключ не схлопывает разные документы.
bot-backend merged commit 04f70b8da0 into main 2026-08-20 10:16:35 +00:00
Author
Collaborator

Прод-проверка после деплоя

Миграция применилась. Сверка со снимком «до», снятым перед мержем:

до после
строк 297 27
уникальных cad_num 27 27
без номера акта 297 27
констрейнт UNIQUE (cad_num, act_number) UNIQUE NULLS NOT DISTINCT (cad_num, act_number)

Число уникальных cad_num не изменилось — удалены ровно копии, ни одной настоящей записи не потеряно. Миграция отмечена в _schema_migrations.

Механизм проверен на живой таблице

Не по коду, а поведением — вставка существующей записи тем же ON CONFLICT DO NOTHING, что в таске, внутри транзакции с откатом:

BEGIN
до_вставки     | 27
INSERT 0 0                ← конфликт пойман, вставлено ноль
после_вставки  | 27
ROLLBACK
после_отката   | 27

INSERT 0 0 — это и есть доказательство: до миграции такая же вставка добавляла бы строку (NULL != NULL, конфликт не наступал). Теперь недельный прогон таски перестаёт копить дубли.

Первая попытка пробы была составлена неполно (не хватило basis_act, NOT NULL) — транзакция откатилась чисто, данные не пострадали; проба переделана.

## Прод-проверка после деплоя Миграция применилась. Сверка со снимком «до», снятым перед мержем: | | до | после | |---|---|---| | строк | 297 | **27** | | уникальных `cad_num` | 27 | **27** | | без номера акта | 297 | 27 | | констрейнт | `UNIQUE (cad_num, act_number)` | **`UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`** | Число уникальных `cad_num` не изменилось — удалены ровно копии, ни одной настоящей записи не потеряно. Миграция отмечена в `_schema_migrations`. ## Механизм проверен на живой таблице Не по коду, а поведением — вставка существующей записи тем же `ON CONFLICT DO NOTHING`, что в таске, внутри транзакции с откатом: ``` BEGIN до_вставки | 27 INSERT 0 0 ← конфликт пойман, вставлено ноль после_вставки | 27 ROLLBACK после_отката | 27 ``` `INSERT 0 0` — это и есть доказательство: до миграции такая же вставка добавляла бы строку (NULL != NULL, конфликт не наступал). Теперь недельный прогон таски перестаёт копить дубли. Первая попытка пробы была составлена неполно (не хватило `basis_act`, NOT NULL) — транзакция откатилась чисто, данные не пострадали; проба переделана.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2966
No description provided.