gendesign/data/sql/190_user_weight_profiles_one_default.sql
bot-backend afa648b21f
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 2m26s
CI / backend-tests (pull_request) Successful in 17m14s
fix(ptica): у пользователя не может быть двух дефолтных профилей весов (#2464)
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>
2026-08-20 16:58:48 +05:00

33 lines
2.3 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 190_user_weight_profiles_one_default.sql
-- #2464 — «дефолтный профиль весов» становится единственным на уровне БД.
--
-- БАГ. create_profile/update_profile делают «снять is_default у всех → поставить
-- новому» двумя отдельными операторами. Между ними инвариант нарушен, и при
-- одновременных запросах у пользователя может оказаться ДВА профиля с
-- is_default=TRUE. Читающий запрос _SELECT_DEFAULT брал LIMIT 1 без ORDER BY,
-- то есть выбор молча перескакивал между ними от запроса к запросу.
--
-- ЧТО ДЕЛАЕМ. Частичный уникальный индекс — «не более одного дефолта на
-- пользователя». Порядок операторов в коде уже правильный (сначала снять, потом
-- поставить), поэтому индекс не мешает штатной переустановке дефолта: после
-- UPDATE ... SET is_default=FALSE дефолтов ноль, и следующая установка проходит.
--
-- БЕЗОПАСНОСТЬ. Проверено на проде 2026-08-20: нарушений нет — у admin один
-- дефолт, у __system__ ноль, ни одного пользователя с двумя. Таблица крошечная
-- (4 строки), создание индекса мгновенное.
--
-- Idempotent: CREATE UNIQUE INDEX IF NOT EXISTS.
-- Apply after: 189_land_reservation_nulls_not_distinct.sql
BEGIN;
-- #2752: блокирующий DDL обязан иметь lock_timeout, иначе встанет в очередь за
-- чужой сессией и уведёт за собой запросы приложения. Пять секунд — про ОЖИДАНИЕ
-- блокировки, не про работу: таблица в четыре строки индексируется мгновенно.
SET LOCAL lock_timeout = '5s';
CREATE UNIQUE INDEX IF NOT EXISTS user_weight_profiles_one_default
ON user_weight_profiles (user_id)
WHERE is_default;
COMMIT;