Skip to content

Commit 0a18f4b

Browse files
authored
feat: make deck creation idempotent with a client-generated key (#90)
* test: require an idempotency key on POST /decks/ and replay it Posting twice with one key must return the first deck and leave a single row. * feat: make deck creation idempotent with a client-generated key CreateDeckUseCase looks the key up inside its transaction and returns the existing deck, so a postgres_retry rerun after a COMMIT that landed, or a client resend, does not create a second deck. PUT /decks/ takes the new DeckUpdate schema without the key. * test: a concurrent create with the same idempotency key returns one deck A connection holds an uncommitted insert with the key while CreateDeckUseCase runs. The lookup version fails with a duplicate-key error once that insert commits. * perf: create a deck in one INSERT ... ON CONFLICT DO UPDATE RETURNING The no-op update on idempotency_key returns the existing row on a replay, and a concurrent request with the same key waits on the row lock instead of failing on the unique constraint.
1 parent 9f808e6 commit 0a18f4b

11 files changed

Lines changed: 127 additions & 4 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ class ListDecksUseCase:
7373

7474
- Every query runs inside `async with self.transaction:`. A query before the block leaves the session in a failed transaction on a lost connection, and the retry then fails with `PendingRollbackError`.
7575
- Commit before leaving the block, reads included, and before any early return. Otherwise `__aexit__` rolls back, which expires the loaded rows.
76-
- A retry runs the whole use case again. If the connection drops during `COMMIT`, the write may already have landed, so a non-idempotent write such as `CreateDeckUseCase` can be applied twice.
76+
- A retry runs the whole use case again. If the connection drops during `COMMIT`, the write may already have landed. A write that creates rows needs something that makes the second run harmless:
77+
- `CreateDeckUseCase` takes a client-generated `idempotency_key` (`schemas.DeckCreate`, unique on `decks`). `DecksRepository.get_or_create` sends one `INSERT ... ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key RETURNING *`. On a conflict the no-op update leaves the row as it was and `RETURNING` hands it back (`DO NOTHING` would return no row), so a retry or a client resend gets the first deck. A concurrent request with the same key waits on the row lock and then gets the same deck. advanced-alchemy's own `upsert` and `get_or_upsert` do a SELECT followed by an INSERT, so they cost two round trips and lose that race.
78+
- `CreateCardsUseCase` relies on the `(deck_id, front)` unique constraint. A replay is rejected as a duplicate-key error rather than creating the cards twice.
79+
- Updates (`UpdateDeckUseCase`, `UpdateCardsUseCase`) write the same values again, so a replay is harmless.
7780

7881
`app/use_cases/` is exempt from ruff's `TC` rules because modern-di reads the dataclass field annotations at runtime.
7982

‎app/api/decks.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def get_deck(
3131
@ROUTER.put("/decks/{deck_id}/")
3232
async def update_deck(
3333
deck_id: int,
34-
data: schemas.DeckCreate,
34+
data: schemas.DeckUpdate,
3535
update_deck_use_case: UpdateDeckUseCase = FromDI(UpdateDeckUseCase),
3636
) -> schemas.Deck:
3737
return schemas.Deck.model_validate(await update_deck_use_case(deck_id=deck_id, data=data))

‎app/models.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing
2+
import uuid
23

34
import sqlalchemy as sa
45
from advanced_alchemy.base import BigIntAuditBase, orm_registry
@@ -11,7 +12,9 @@
1112

1213
class Deck(BigIntAuditBase):
1314
__tablename__ = "decks"
15+
__table_args__ = (sa.UniqueConstraint("idempotency_key", name="deck_idempotency_key_uc"),)
1416

17+
idempotency_key: orm.Mapped[uuid.UUID] = orm.mapped_column(sa.Uuid, nullable=False)
1518
name: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
1619
description: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True)
1720
cards: orm.Mapped[list[Card]] = orm.relationship("Card", lazy="noload", uselist=True)

‎app/repositories.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
55
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
66
from sqlalchemy import orm
7+
from sqlalchemy.dialects import postgresql
78

89
from app import models, schemas
910

@@ -21,6 +22,14 @@ class BaseRepository(SQLAlchemyAsyncRepository[models.Deck]):
2122
async def list_decks(self) -> Sequence[models.Deck]:
2223
return await self.get_many(order_by=models.Deck.id.asc())
2324

25+
async def get_or_create(self, data: schemas.DeckCreate) -> models.Deck:
26+
statement = postgresql.insert(models.Deck).values(**data.model_dump())
27+
statement = statement.on_conflict_do_update(
28+
index_elements=[models.Deck.idempotency_key],
29+
set_={"idempotency_key": statement.excluded.idempotency_key},
30+
)
31+
return (await self.repository.session.scalars(statement.returning(models.Deck))).one()
32+
2433
async def fetch_with_cards(self, deck_id: int) -> models.Deck:
2534
return await self.get_one(
2635
models.Deck.id == deck_id,

‎app/schemas.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import TYPE_CHECKING, Self
23

34
import pydantic
@@ -45,6 +46,10 @@ class DeckBase(Base):
4546

4647

4748
class DeckCreate(DeckBase):
49+
idempotency_key: uuid.UUID
50+
51+
52+
class DeckUpdate(DeckBase):
4853
pass
4954

5055

‎app/use_cases/create_deck.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ class CreateDeckUseCase:
1515
@postgres_retry
1616
async def __call__(self, *, data: schemas.DeckCreate) -> models.Deck:
1717
async with self.transaction:
18-
deck: typing.Final = await self.decks_repository.create(data.model_dump())
18+
deck: typing.Final = await self.decks_repository.get_or_create(data)
1919
await self.transaction.commit()
2020
return deck

‎app/use_cases/update_deck.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class UpdateDeckUseCase:
1313
decks_repository: DecksRepository
1414

1515
@postgres_retry
16-
async def __call__(self, *, deck_id: int, data: schemas.DeckCreate) -> models.Deck:
16+
async def __call__(self, *, deck_id: int, data: schemas.DeckUpdate) -> models.Deck:
1717
async with self.transaction:
1818
deck: typing.Final = await self.decks_repository.update(data=data.model_dump(), item_id=deck_id)
1919
await self.transaction.commit()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""deck idempotency key.
2+
3+
Revision ID: 5d1c0f3a9b2e
4+
Revises: abb62721e019
5+
Create Date: 2026-10-16 20:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "5d1c0f3a9b2e"
15+
down_revision = "abb62721e019"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
op.add_column(
22+
"decks",
23+
sa.Column("idempotency_key", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")),
24+
)
25+
op.alter_column("decks", "idempotency_key", server_default=None)
26+
op.create_unique_constraint("deck_idempotency_key_uc", "decks", ["idempotency_key"])
27+
28+
29+
def downgrade() -> None:
30+
op.drop_constraint("deck_idempotency_key_uc", "decks", type_="unique")
31+
op.drop_column("decks", "idempotency_key")

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ ignore = [
6464
"CPY001", # Missing copyright notice at top of file
6565
]
6666
isort.lines-after-imports = 2
67+
flake8-type-checking.runtime-evaluated-base-classes = ["pydantic.BaseModel", "advanced_alchemy.base.BigIntAuditBase"]
6768
isort.no-lines-before = ["standard-library", "local-folder"]
6869

6970
[tool.ruff.lint.extend-per-file-ignores]

‎tests/test_create_deck.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import asyncio
2+
import typing
3+
import uuid
4+
5+
import sqlalchemy as sa
6+
from db_retry import Transaction
7+
8+
from app import models, schemas
9+
from app.repositories import DecksRepository
10+
from app.resources.db import create_primary_sa_engine, create_session
11+
from app.use_cases.create_deck import CreateDeckUseCase
12+
13+
14+
if typing.TYPE_CHECKING:
15+
from sqlalchemy.ext.asyncio import AsyncEngine
16+
17+
18+
async def _wait_until_blocked_on_a_lock(engine: AsyncEngine, pid: int) -> None:
19+
wait_event_type = None
20+
while wait_event_type != "Lock":
21+
await asyncio.sleep(0.01)
22+
async with engine.connect() as connection:
23+
wait_event_type = await connection.scalar(
24+
sa.text("SELECT wait_event_type FROM pg_stat_activity WHERE pid = :pid"), {"pid": pid}
25+
)
26+
27+
28+
async def test_create_deck_returns_the_deck_a_concurrent_request_created_with_the_same_key() -> None:
29+
engine: typing.Final = create_primary_sa_engine()
30+
idempotency_key: typing.Final = uuid.uuid4()
31+
try:
32+
async with engine.connect() as concurrent_request:
33+
await concurrent_request.execute(
34+
sa.insert(models.Deck).values(idempotency_key=idempotency_key, name="first")
35+
)
36+
session: typing.Final = create_session(engine)
37+
pid: typing.Final = await (await session.connection()).scalar(sa.text("SELECT pg_backend_pid()"))
38+
use_case: typing.Final = CreateDeckUseCase(
39+
transaction=Transaction(session=session), decks_repository=DecksRepository(session=session)
40+
)
41+
pending: typing.Final = asyncio.create_task(
42+
use_case(data=schemas.DeckCreate(idempotency_key=idempotency_key, name="second"))
43+
)
44+
await asyncio.wait_for(_wait_until_blocked_on_a_lock(engine, pid), timeout=5)
45+
await concurrent_request.commit()
46+
deck: typing.Final = await pending
47+
assert deck.name == "first"
48+
finally:
49+
async with engine.begin() as connection:
50+
await connection.execute(sa.delete(models.Deck).where(models.Deck.idempotency_key == idempotency_key))
51+
await engine.dispose()

0 commit comments

Comments
 (0)