Skip to content

Drop unique order constraint on flashcard to fix race condition#15

Merged
tpaulshippy merged 3 commits into
mainfrom
fix/flashcard-order-race-condition-v2
May 25, 2026
Merged

Drop unique order constraint on flashcard to fix race condition#15
tpaulshippy merged 3 commits into
mainfrom
fix/flashcard-order-race-condition-v2

Conversation

@tpaulshippy
Copy link
Copy Markdown
Owner

@tpaulshippy tpaulshippy commented May 25, 2026

Summary

Removes the UNIQUE constraint on (deck, order) to fix a race condition on SQLite where concurrent flashcard creates could compute the same max_order and collide.

Why

  • select_for_update() is a no-op on SQLite — can't lock rows
  • The constraint catches races but explodes with IntegrityError + TransactionManagementError
  • Keeping unique order per deck adds complexity (retry loops, savepoints) for no real benefit

What changed

  • Removed UniqueConstraint(deck, order) from the Flashcard model
  • Simplified perform_create — no retry loops, no savepoints, no select_for_update. Just computes max_order and saves
  • Added created_at tiebreaker to the queryset ordering so cards with the same order sort consistently
  • Cleaned up unused imports (logging, transaction, IntegrityError)
  • Migration: 0036_remove_flashcard_unique_flashcard_order_per_deck

Effect

Concurrent creates that race on max_order now silently produce matching order values. The cards still display in correct sequence because created_at breaks the tie.

Summary by CodeRabbit

  • Improvements
    • Optimized flashcard retrieval and ordering logic
    • Simplified flashcard creation process for better performance

Review Change Stack

Drop select_for_update (no-op on SQLite) and retry on IntegrityError
using savepoints (nested transaction.atomic) up to 3 attempts.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

📝 Walkthrough

Walkthrough

The 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.

Changes

Flashcard Uniqueness Constraint and Creation Simplification

Layer / File(s) Summary
Remove unique constraint from database and model
back/bots/migrations/0036_remove_flashcard_unique_flashcard_order_per_deck.py, back/bots/models/flashcard.py
Migration removes the unique_flashcard_order_per_deck constraint from the flashcard table; Flashcard.Meta removes the UniqueConstraint on (deck, order) while retaining the Index.
Simplify flashcard creation flow
back/bots/viewsets/flashcard_viewset.py
Module imports remove logging, transaction, IntegrityError, and ValidationError. FlashcardViewSet.perform_create eliminates the transaction.atomic() block and retry loop, computing max_order once and saving in a single operation. FlashcardViewSet.get_queryset orders by both order and created_at for deterministic ordering.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • tpaulshippy/bots#10: Addresses flashcard ordering under concurrency by using select_for_update() on deck row; this PR removes the constraint and simplifies the retry-based approach to a single deterministic computation.
  • tpaulshippy/bots#11: Also changes flashcard creation ordering logic under concurrency by adding select_for_update() when calculating max(order); directly related to the same code path that is refactored here.

Poem

🐰 Order matters, yet constraint removed,
No more retries in atomic grooves,
One flash, one save, one truth so clean,
The swiftest deck the database's seen!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main change: removing the unique order constraint on the Flashcard model to resolve a race condition issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/flashcard-order-race-condition-v2

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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.
@tpaulshippy tpaulshippy changed the title Fix flashcard order race condition with retry logic Drop unique order constraint on flashcard to fix race condition May 25, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
back/bots/viewsets/flashcard_viewset.py (1)

18-29: ⚡ Quick win

Extract deck resolution into a helper and log lookup failures with context.

FlashcardViewSet.get_queryset and FlashcardViewSet.perform_create duplicate 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 raising NotFound.

♻️ 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

📥 Commits

Reviewing files that changed from the base of the PR and between c5d0138 and 7880ce7.

📒 Files selected for processing (3)
  • back/bots/migrations/0036_remove_flashcard_unique_flashcard_order_per_deck.py
  • back/bots/models/flashcard.py
  • back/bots/viewsets/flashcard_viewset.py
💤 Files with no reviewable changes (1)
  • back/bots/models/flashcard.py

@tpaulshippy tpaulshippy merged commit 8186245 into main May 25, 2026
4 checks passed
@tpaulshippy tpaulshippy deleted the fix/flashcard-order-race-condition-v2 branch May 25, 2026 20:44
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