fix(ptica): у пользователя не может быть двух дефолтных профилей весов (#2464) #2976
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2976
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2464-default-profile-unique"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Пункт эпика #2464:
weight_profiles.py:99.Два дефекта одного места
create_profile/update_profileделают «снятьis_defaultу всех → поставить новому» двумя отдельными операторами. Между ними инвариант нарушен: при одновременных запросах у пользователя может оказаться два профиля сis_default=TRUE.А читающий
_SELECT_DEFAULTбралLIMIT 1безORDER BY— выбор молча перескакивал между ними от запроса к запросу.Два рубежа, а не один
(user_id) WHERE is_default— два дефолта невозможны на уровне БДORDER BY id ASCСоседние запросы этого же файла тай-брейк по
idуже имели — приводим к ним.Индекс не мешает штатной переустановке дефолта: порядок операторов в коде уже правильный (сначала снять у всех, потом поставить), поэтому в момент проверки дефолтов ноль. Проверено отдельным тестом.
Безопасность миграции
На проде нарушений нет: у
adminодин дефолт, у__system__ноль, ни одного пользователя с двумя. Таблица в 4 строки — индексируется мгновенно.lock_timeoutпроставлен по #2752.Тесты
Поведение на живом Postgres: вторая установка дефолта отвергается базой.
Плюс фальсификация — без индекса два дефолта вставляются молча. Без неё зелёный тест неотличим от «оно и так не вставлялось».
Плюс два контроля: переустановка дефолта работает; разные пользователи сохраняют свои дефолты.
Про размещение теста
Проверку
ORDER BYвынес вtests/services/site_finder, а не внёс вskip_allowlist: живой БД она не требует, и пропускаться вместе с DB-тестами ей незачем. Противorigin/mainкраснеет, показывая запрос без тай-брейка.Прогоны
create_profile/update_profile делают «снять is_default у всех → поставить новому» двумя отдельными операторами. Между ними инвариант нарушен, и при одновременных запросах у пользователя может оказаться ДВА профиля с is_default=TRUE. А читающий _SELECT_DEFAULT брал LIMIT 1 БЕЗ ORDER BY — выбор молча перескакивал между ними от запроса к запросу. Два рубежа, а не один: миграция 190 — частичный уникальный индекс (user_id) WHERE is_default: два дефолта становятся невозможными на уровне БД; ORDER BY id — детерминированный выбор, если индекс когда-нибудь снимут. Соседние запросы этого файла тай-брейк по id уже имеют. Индекс не мешает штатной переустановке дефолта: порядок операторов в коде уже правильный (сначала снять у всех, потом поставить), поэтому в момент проверки дефолтов ноль. Это отдельно проверено тестом. Безопасность миграции: на проде нарушений нет — у admin один дефолт, у __system__ ноль, ни одного пользователя с двумя. Таблица в 4 строки, индексируется мгновенно. lock_timeout проставлен по #2752. Тест проверяет ПОВЕДЕНИЕ на живом Postgres: вторая установка дефолта отвергается базой. Плюс фальсификация — без индекса два дефолта вставляются молча; без неё зелёный тест неотличим от «оно и так не вставлялось». Плюс два контроля: переустановка дефолта работает, разные пользователи сохраняют свои. Тест про ORDER BY вынесен в tests/services/site_finder, а НЕ внесён в skip_allowlist: живой БД он не требует, и пропускаться вместе с DB-тестами ему незачем. Против origin/main он краснеет, показывая запрос без тай-брейка. Прогоны: без БД — 648 passed rc=0; с БД — 5 passed rc=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Проверено на проде
Деплой
131b83c2зелёный, код в живом контейнере (не по статусу джобы):Миграция 190 применилась — индекс на проде есть:
Индекс не декорация — фальсифицирован на живых данных (вставка в SAVEPOINT с откатом):
Честно о масштабе
Дубликатов на проде не было (0 пользователей с >1 дефолтом, всего 4 профиля). Защита предупредительная, а не исправительная — она не чинит существующую порчу, а закрывает возможность её создать.
ORDER BY id ASCпри этом делает выбор победителя детерминированным и сейчас: без него порядок был на усмотрение планировщика.Побочно замечено:
user_weight_profiles_default_idx(неуникальный,(user_id) WHERE is_default = true) теперь полностью перекрыт новым уникальным — избыточная запись на каждой вставке. Отдельным пунктом, здесь не трогаю.