Drop unique order constraint on flashcard to fix race condition#15
Conversation
Drop select_for_update (no-op on SQLite) and retry on IntegrityError using savepoints (nested transaction.atomic) up to 3 attempts.
📝 WalkthroughWalkthroughThe PR removes the database-level unique constraint on (deck, order) from the Flashcard model and simplifies the flashcard creation flow. A migration removes the constraint, the model removes its declaration, and the viewset eliminates atomic transaction retry logic. The flashcard queryset now orders by order and created_at for stability. ChangesFlashcard Uniqueness Constraint and Creation Simplification
Sequence DiagramsequenceDiagram
participant Client
participant FlashcardViewSet
participant Flashcard
participant Database
Client->>FlashcardViewSet: POST flashcard
FlashcardViewSet->>Database: SELECT max(order) for deck
Database-->>FlashcardViewSet: max_order
FlashcardViewSet->>Flashcard: order = max_order + 1
FlashcardViewSet->>Database: INSERT flashcard (single save, no retry)
Database-->>Client: 201 Created
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Concurrent flashcard creates can race on max_order computation. Since select_for_update is a no-op on SQLite, removing the UNIQUE constraint on (deck, order) is simpler and correct: collisions just result in equal order values, tiebroken by created_at.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
back/bots/viewsets/flashcard_viewset.py (1)
18-29: ⚡ Quick winExtract deck resolution into a helper and log lookup failures with context.
FlashcardViewSet.get_querysetandFlashcardViewSet.perform_createduplicate the same deck parsing/lookup/error path. Pull that into a private helper (for example,_resolve_deck_or_not_found) and add a contextual log in the final failure branch before raisingNotFound.♻️ Proposed refactor
+import logging import uuid @@ from rest_framework import viewsets from rest_framework.exceptions import NotFound @@ +logger = logging.getLogger(__name__) + class FlashcardViewSet(viewsets.ModelViewSet): @@ + def _resolve_deck_or_not_found(self, deck_id): + try: + return Deck.objects.get(deck_id=uuid.UUID(deck_id)) + except (ValueError, Deck.DoesNotExist): + try: + return Deck.objects.get(id=deck_id) + except (ValueError, Deck.DoesNotExist): + logger.warning( + "Flashcard deck lookup failed", + extra={"deck_pk": deck_id, "user_id": getattr(self.request.user, "id", None)}, + ) + raise NotFound("Deck not found") + def get_queryset(self): - deck_id = self.kwargs['deck_pk'] - - try: - deck_uuid = uuid.UUID(deck_id) - deck = Deck.objects.get(deck_id=deck_uuid) - except (ValueError, Deck.DoesNotExist): - try: - deck = Deck.objects.get(id=deck_id) - except (ValueError, Deck.DoesNotExist): - raise NotFound("Deck not found") + deck = self._resolve_deck_or_not_found(self.kwargs['deck_pk']) @@ def perform_create(self, serializer): - deck_id = self.kwargs['deck_pk'] - - try: - deck_uuid = uuid.UUID(deck_id) - deck = Deck.objects.get(deck_id=deck_uuid) - except (ValueError, Deck.DoesNotExist): - try: - deck = Deck.objects.get(id=deck_id) - except (ValueError, Deck.DoesNotExist): - raise NotFound("Deck not found") + deck = self._resolve_deck_or_not_found(self.kwargs['deck_pk'])As per coding guidelines, "Keep methods focused and concise, under 10 lines when possible, extracting complex logic into separate methods" and "Log errors with sufficient context for debugging".
Also applies to: 59-70
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@back/bots/viewsets/flashcard_viewset.py` around lines 18 - 29, Extract the duplicated deck parsing/lookup logic from FlashcardViewSet.get_queryset and FlashcardViewSet.perform_create into a private helper named _resolve_deck_or_not_found(self, deck_id) that attempts uuid.UUID(deck_id) then Deck.objects.get(deck_id=...) and falls back to Deck.objects.get(id=...). In the helper, when both lookups fail, log a contextual error (including the original deck_id string and the request user if available) before raising rest_framework.exceptions.NotFound("Deck not found"). Replace the duplicated blocks in get_queryset and perform_create to call self._resolve_deck_or_not_found(deck_id) and return/use the resolved Deck.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@back/bots/viewsets/flashcard_viewset.py`:
- Around line 18-29: Extract the duplicated deck parsing/lookup logic from
FlashcardViewSet.get_queryset and FlashcardViewSet.perform_create into a private
helper named _resolve_deck_or_not_found(self, deck_id) that attempts
uuid.UUID(deck_id) then Deck.objects.get(deck_id=...) and falls back to
Deck.objects.get(id=...). In the helper, when both lookups fail, log a
contextual error (including the original deck_id string and the request user if
available) before raising rest_framework.exceptions.NotFound("Deck not found").
Replace the duplicated blocks in get_queryset and perform_create to call
self._resolve_deck_or_not_found(deck_id) and return/use the resolved Deck.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f3dd89f6-df63-462f-826e-d3063051b3d0
📒 Files selected for processing (3)
back/bots/migrations/0036_remove_flashcard_unique_flashcard_order_per_deck.pyback/bots/models/flashcard.pyback/bots/viewsets/flashcard_viewset.py
💤 Files with no reviewable changes (1)
- back/bots/models/flashcard.py
Summary
Removes the UNIQUE constraint on
(deck, order)to fix a race condition on SQLite where concurrent flashcard creates could compute the samemax_orderand collide.Why
select_for_update()is a no-op on SQLite — can't lock rowsIntegrityError+TransactionManagementErrorWhat changed
UniqueConstraint(deck, order)from the Flashcard modelperform_create— no retry loops, no savepoints, noselect_for_update. Just computesmax_orderand savescreated_attiebreaker to the queryset ordering so cards with the sameordersort consistentlylogging,transaction,IntegrityError)0036_remove_flashcard_unique_flashcard_order_per_deckEffect
Concurrent creates that race on
max_ordernow silently produce matchingordervalues. The cards still display in correct sequence becausecreated_atbreaks the tie.Summary by CodeRabbit