fix(tradein): миграция платежей 277 → 279 (столкновение номеров) + lock_timeout
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped

Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке
витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности
зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки
tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими
открытыми ветками.

Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279.
Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь,
по которому тест читает предикат частичного UNIQUE.

Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в
SET LOCAL lock_timeout = '5s' — гейт #2752.
This commit is contained in:
bot-backend 2026-08-29 19:15:23 +05:00
parent c5315539fa
commit a48070dd89
3 changed files with 14 additions and 11 deletions

View file

@ -14,7 +14,7 @@ Merge безопасен на выключенном контуре — на п
Идемпотентность держится на БД, а не на «проверить-потом-вставить»
Все три гонки, которые здесь реальны (двойной клик по кнопке оплаты; банк шлёт
AUTHORIZED и CONFIRMED одновременно; банк ретраит нотификацию почасово сутки),
закрыты UNIQUE-ключами миграций 233 и 277 + `ON CONFLICT DO NOTHING`. Пара
закрыты UNIQUE-ключами миграций 233 и 279 + `ON CONFLICT DO NOTHING`. Пара
«SELECT, потом INSERT» здесь была бы дефектом: между ними успевает пройти
параллельный запрос, и выдача (или холд на карте) происходит дважды. SELECT
живого платежа в `checkout` остался, но только как быстрый путь для честного
@ -157,7 +157,7 @@ _PRE_CONFIRM_STATUSES = frozenset(
# после отказа человек вправе попробовать оплатить заново.
#
# Этот же список — предикат частичного UNIQUE(estimate_id, product_code)
# миграции 277, который и делает «один живой платёж» свойством БД, а не
# миграции 279, который и делает «один живой платёж» свойством БД, а не
# порядка выполнения. Расхождение кода и миграции ловит
# tests/test_payments_router.py::test_live_status_predicate_matches_code.
_REUSABLE_STATUSES = _PRE_CONFIRM_STATUSES
@ -246,7 +246,7 @@ def checkout(
"""Создаёт платёж и возвращает `PaymentURL` формы Т-Банка.
Идемпотентность свойство БД, а не порядка выполнения: частичный UNIQUE
(estimate_id, product_code) по живым статусам (миграция 277) физически не
(estimate_id, product_code) по живым статусам (миграция 279) физически не
даёт существовать двум живым платежам по одной оценке, а `ON CONFLICT DO
NOTHING` превращает проигрыш в гонке в ответ, а не во второй `Init` (и,
значит, во второй холд на карте покупателя).
@ -291,7 +291,7 @@ def checkout(
# Освобождаем пару (estimate_id, product_code) от брошенных попыток ДО
# проверки живого платежа: иначе и переиспользование вернуло бы мёртвую
# ссылку, и UNIQUE миграции 277 не дал бы создать новую (см. комментарий у
# ссылку, и UNIQUE миграции 279 не дал бы создать новую (см. комментарий у
# _ABANDONED_AFTER_MINUTES).
db.execute(
text(

View file

@ -1,4 +1,4 @@
-- 277_payments_live_checkout_uidx.sql
-- 279_payments_live_checkout_uidx.sql
-- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на
-- пару (estimate_id, product_code).
--
@ -37,6 +37,9 @@
-- ничью запись. IF NOT EXISTS — повторное применение безвредно.
BEGIN;
-- Конвенция проекта (#2752): CREATE UNIQUE INDEX на существующей таблице берёт
-- блокировку и без lock_timeout встанет в очередь за чужой сессией.
SET LOCAL lock_timeout = '5s';
CREATE UNIQUE INDEX IF NOT EXISTS payments_live_estimate_product_uidx
ON payments (estimate_id, product_code)

View file

@ -83,7 +83,7 @@ class _FakeDb:
_NOTIFICATION_KEY = ("tbank_payment_id", "status", "amount_kopecks", "token")
_ENTITLEMENT_KEY = ("payment_id", "kind", "ref_id")
# Частичный UNIQUE миграции 277: ключ (estimate_id, product_code), предикат —
# Частичный UNIQUE миграции 279: ключ (estimate_id, product_code), предикат —
# «живые» статусы. Переписан здесь ПО МИГРАЦИИ, а не импортирован из
# payments.py: иначе тест поехал бы вслед за дефектом. Совпадение списка с
# кодом отдельно гейтит test_live_status_predicate_matches_code.
@ -206,7 +206,7 @@ class _FakeDb:
return _Result(row)
def _insert_payment(self, sql: str, params: dict[str, Any]) -> _Result:
"""Ведёт себя как Postgres с частичным UNIQUE миграции 277.
"""Ведёт себя как Postgres с частичным UNIQUE миграции 279.
Конфликт наступает только когда УЖЕ есть строка с той же парой
(estimate_id, product_code) И статусом из предиката ровно как у
@ -635,7 +635,7 @@ def test_price_matches_published_offer() -> None:
def test_live_status_predicate_matches_code() -> None:
"""Предикат частичного UNIQUE (277) и `_REUSABLE_STATUSES` — один список.
"""Предикат частичного UNIQUE (279) и `_REUSABLE_STATUSES` — один список.
Индекс шире кода запрещает легитимную повторную попытку оплаты; индекс уже
кода пропускает второй холд на карте. И то и другое про деньги, поэтому
@ -643,10 +643,10 @@ def test_live_status_predicate_matches_code() -> None:
"""
from app.api.v1.payments import _REUSABLE_STATUSES
path = _REPO_ROOT / "backend" / "data" / "sql" / "277_payments_live_checkout_uidx.sql"
path = _REPO_ROOT / "backend" / "data" / "sql" / "279_payments_live_checkout_uidx.sql"
source = path.read_text(encoding="utf-8")
predicate = re.search(r"AND status IN \((.*?)\)", source, re.S)
assert predicate is not None, "предикат по статусам не найден в миграции 277"
assert predicate is not None, "предикат по статусам не найден в миграции 279"
assert set(re.findall(r"'([^']+)'", predicate.group(1))) == set(_REUSABLE_STATUSES)
@ -704,7 +704,7 @@ def test_parallel_checkout_does_not_create_second_payment(
) -> None:
"""Двойной клик: соперник уже вставил строку, но ссылки от банка ещё нет.
Второй запрос обязан отбиться о частичный UNIQUE (миграция 277) и не пойти
Второй запрос обязан отбиться о частичный UNIQUE (миграция 279) и не пойти
в банк второй Init это второй холд на карте покупателя. Ответ 409, а не
выдуманная ссылка.