fix(tradein): миграция платежей 277 → 279 (столкновение номеров) + lock_timeout
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 19s
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
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 19s
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
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
Два параллельных агента взяли ОДИН номер: 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:
parent
cd0522ceae
commit
93da29ce81
3 changed files with 14 additions and 11 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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, а не
|
||||
выдуманная ссылка.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue