Skip to content

feat: make deck creation idempotent with a client-generated key - #90

Merged
lesnik512 merged 5 commits into
mainfrom
feat/deck-idempotency-key
Oct 16, 2026
Merged

lesnik512 merged 5 commits into
mainfrom
feat/deck-idempotency-key

Conversation

@lesnik512

@lesnik512 lesnik512 commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

POST /api/decks/ now requires a client-generated idempotency_key. Sending the same key again returns the deck created the first time instead of creating a second one.

This closes the gap the pre-ping PR left open. A postgres_retry rerun after a COMMIT that landed but whose connection dropped would create the deck twice. A client resending after a timeout would too. advanced-alchemy has no single-query way to do this. Its upsert and get_or_upsert both run a SELECT followed by an INSERT, so the deck is created with one Postgres statement instead.

Changes

  • decks.idempotency_key: a UUID column, not null, with the unique constraint deck_idempotency_key_uc. The migration backfills existing rows with gen_random_uuid(), then drops the default.
  • schemas.DeckCreate gains idempotency_key: uuid.UUID. PUT /api/decks/{id}/ takes a new schemas.DeckUpdate without the key, since an update is already safe to repeat.
  • DecksRepository.get_or_create sends one INSERT ... ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key RETURNING *. On a conflict, the update leaves the row as it was and RETURNING hands back the existing deck. DO NOTHING would return no row and need a second query. updated_at doesn't change, because SQLAlchemy only applies onupdate defaults to columns listed in set_. CreateDeckUseCase calls it inside its transaction.
  • pyproject.toml marks pydantic.BaseModel and BigIntAuditBase as runtime-evaluated base classes for ruff's TC rules. Without that, ruff check --fix moves import uuid into a TYPE_CHECKING block in models.py and schemas.py, and SQLAlchemy and pydantic then fail to resolve the annotation.
  • AGENTS.md now says how each write handles a replay. CreateCardsUseCase relies on the existing (deck_id, front) unique constraint, so a replay is rejected as a duplicate instead of creating the cards twice. The updates write the same values again.

Tests

  • test_post_decks sends a key.
  • A POST without a key returns 422.
  • Two POSTs with one key return the same deck, and GET /api/decks/ lists one deck.
  • tests/test_create_deck.py: a separate connection holds an uncommitted insert with the key while CreateDeckUseCase runs. The test polls pg_stat_activity until the use case is blocked on the lock, commits the insert, and checks that the use case returns that deck. With a lookup-then-create version, this test failed with UniqueViolationError. It deletes its row afterwards.

Each new test failed before the change it covers. Ruff and ty pass, and all 40 tests pass with 100% coverage against a local Postgres 14. The migration upgrades and downgrades cleanly. alembic check shows no drift on the new column. It does report older drift (id is Integer in the init migration but BigInteger in the model, and cards.deck_id is nullable), which this PR leaves alone.

Limits

  • get_or_create is Postgres-specific, and it runs on the session directly, so advanced-alchemy doesn't wrap its errors. The templates only target Postgres.
  • A replay writes a new row version (the no-op update), which leaves a dead tuple. Replays are rare.
  • Any client of POST /api/decks/ now has to send a key, which breaks existing clients.

Posting twice with one key must return the first deck and leave a single row.
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.
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.
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.
@lesnik512
lesnik512 merged commit 0a18f4b into main Oct 16, 2026
2 checks passed
@lesnik512
lesnik512 deleted the feat/deck-idempotency-key branch October 16, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant