Skip to content

Commit d100eca

Browse files
committed
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 1ed7f0f commit d100eca

3 files changed

Lines changed: 11 additions & 7 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class ListDecksUseCase:
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.
7676
- 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`). It looks the key up inside the transaction and returns the existing deck when it finds one, so a retry, or a client resending the request, gets the first deck back. Two concurrent requests with one key can still both miss the lookup; the second then fails on the unique constraint as a duplicate-key error.
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.
7878
- `CreateCardsUseCase` relies on the `(deck_id, front)` unique constraint. A replay is rejected as a duplicate-key error rather than creating the cards twice.
7979
- Updates (`UpdateDeckUseCase`, `UpdateCardsUseCase`) write the same values again, so a replay is harmless.
8080

‎app/repositories.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
44
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
55
from sqlalchemy import orm
6+
from sqlalchemy.dialects import postgresql
67

78
from app import models, schemas
89

910

1011
if TYPE_CHECKING:
11-
import uuid
1212
from collections.abc import Sequence
1313

1414

@@ -18,8 +18,13 @@ class BaseRepository(SQLAlchemyAsyncRepository[models.Deck]):
1818

1919
repository_type = BaseRepository
2020

21-
async def fetch_by_idempotency_key(self, idempotency_key: uuid.UUID) -> models.Deck | None:
22-
return await self.get_one_or_none(idempotency_key=idempotency_key)
21+
async def get_or_create(self, data: schemas.DeckCreate) -> models.Deck:
22+
statement = postgresql.insert(models.Deck).values(**data.model_dump())
23+
statement = statement.on_conflict_do_update(
24+
index_elements=[models.Deck.idempotency_key],
25+
set_={"idempotency_key": statement.excluded.idempotency_key},
26+
)
27+
return (await self.repository.session.scalars(statement.returning(models.Deck))).one()
2328

2429
async def fetch_with_cards(self, deck_id: int) -> models.Deck:
2530
return await self.get_one(

‎app/use_cases/create_deck.py‎

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

34
from db_retry import Transaction, postgres_retry
45

@@ -14,8 +15,6 @@ class CreateDeckUseCase:
1415
@postgres_retry
1516
async def __call__(self, *, data: schemas.DeckCreate) -> models.Deck:
1617
async with self.transaction:
17-
deck = await self.decks_repository.fetch_by_idempotency_key(data.idempotency_key)
18-
if deck is None:
19-
deck = await self.decks_repository.create(data.model_dump())
18+
deck: typing.Final = await self.decks_repository.get_or_create(data)
2019
await self.transaction.commit()
2120
return deck

0 commit comments

Comments
 (0)