Skip to content

Stop recycling game ids and lock the game row on delete - #1313

Open
johnpooch wants to merge 2 commits into
mainfrom
claude/sentry-issue-144911237-cwb4ck
Open

Stop recycling game ids and lock the game row on delete#1313
johnpooch wants to merge 2 commits into
mainfrom
claude/sentry-issue-144911237-cwb4ck

Conversation

@johnpooch

Copy link
Copy Markdown
Owner

What this PR does

Fixes the ForeignKeyViolation raised at COMMIT by DELETE /game/{game_id}/delete/ (Sentry DIPLICITY-API-AA): update or delete on table "game_game" violates foreign key constraint "member_member_game_id_f16d3bd2_fk_game_game_id".

A game id was the slug of its name (service/game/models.py:506), falling back to a uuid-suffixed form only when the bare slug was already taken. Deleting a game therefore freed its id, and the next game with the same name reclaimed it. GameCloneToSandboxSerializer makes that churn constant: it deletes the user's oldest sandbox and then creates a new game named f"{source_game.name} (Sandbox)" (service/game/serializers.py:637-641), i.e. the exact name whose slug it just freed.

A request holding the freed id then acts on an unrelated row. perform_destroy collected one game's members, deleted whichever row now held that id, and left the newer game's members behind. member_member.game_id is DEFERRABLE INITIALLY DEFERRED with ON DELETE NO ACTION (verified against production pg_constraint), so Postgres checks at COMMIT, finds no game row for the key and 25 orphaned members, and rejects the transaction.

Two changes:

  • Always suffix the slug (service/game/models.py). An id is issued once and never reissued, so a stale id can never resolve to a different game. The availability check and the IntegrityError retry it needed both go away — which is also why the create query-count assertions drop by 3.
  • Lock the game row on delete (service/game/views.py). Django cascades in Python: the unlocked, autocommit delete collected children in one snapshot and removed them in another, leaving a window for concurrent writes. perform_destroy now re-fetches the game under select_for_update() inside the transaction that deletes it, and re-checks permissions against the locked row — the pattern SeatClaimMixin already uses.

A concurrent delete of a game that is already gone now returns 404 rather than 500.

Checklist

  • This PR does one thing — no unrelated fixes, refactors, or drive-by cleanups bundled in
  • For PRs of any significant complexity: I ran /review-pr against this PR in Claude Code and addressed (or responded to) its findings
  • Tests cover the change
  • Screenshots embedded in the PR description for any visual changes (see CLAUDE.md) — n/a, backend only

Tests: full backend suite green (2350 passed, 8 skipped). New coverage — test_recreated_sandbox_game_does_not_reuse_deleted_id reproduces the production sequence (create sandbox → delete → recreate under the same name) over HTTP, and test_delete_sandbox_game_twice_returns_404 pins the repeat-delete behaviour.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KrbHq68GmJquPDib3fYkc3


Generated by Claude Code

A game id was the slug of its name, and only fell back to a uuid-suffixed
form when the bare slug was already taken. Deleting a game therefore freed
its id, and the next game with that name reclaimed it. A request holding
the freed id then acted on an unrelated row: DELETE /game/<id>/delete/
collected one game's members, deleted the row a later game had created
under the same id, and left that game's members behind, so the deferred
foreign key rejected the transaction at COMMIT with a ForeignKeyViolation.

Always suffix the slug, so an id is issued once and never reissued. The
availability check and the IntegrityError retry it needed both go away.

Delete also ran unlocked and outside a transaction of its own: Django
cascades in Python, collecting children in one snapshot and removing them
in another. Re-fetch the game under select_for_update() inside the
transaction that deletes it, and re-check permissions against the locked
row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrbHq68GmJquPDib3fYkc3
…44911237-cwb4ck

# Conflicts:
#	.claude/rules/backend/models.md
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.

2 participants