Skip to content

fix: race on POST /api/games (UNIQUE constraint 500)#61

Merged
Calixteair merged 2 commits into
mainfrom
fix/start-game-race
May 13, 2026
Merged

fix: race on POST /api/games (UNIQUE constraint 500)#61
Calixteair merged 2 commits into
mainfrom
fix/start-game-race

Conversation

@Calixteair
Copy link
Copy Markdown
Owner

What

Hitting "Play this grid" on a duel link sometimes returned 500 Internal Server Error:

```
duplicate key value violates unique constraint "uniq_games_grid_device"
```

Two POST `/api/games` requests went out in the same tick, the second collided with the UNIQUE (grid_id, device_id) DB constraint.

Why

Two layers contributed:

  1. Frontend — `Grid.svelte`'s mount `$effect` calls `loadGrid`. `loadGrid` reads `store.state` (a Svelte rune) so the effect re-subscribes; when `startGame` mutates the store, the effect re-runs and fires a second POST before the first response lands.
  2. Backend — the SELECT-then-INSERT pre-check in `start_game` has a window: the second concurrent request reads `existing = None` and tries to INSERT, hitting the UNIQUE index.

How

Backend (`server/src/routes/games.rs`)

Race-tolerant insert: when the INSERT errors with a Postgres unique_violation on the games (grid_id, device_id) index, we re-read the winning row and return `resume_existing` on it — same end-state as the pre-check branch, just resistant to the SELECT/INSERT window. A finished game still surfaces as 409 Conflict; the race fallback only resumes active games.

New helper `is_unique_violation()` pattern-matches the SeaORM error on SQLSTATE `23505` or the index name (string match — SeaORM stringifies the underlying sqlx error).

Frontend (`web/src/lib/components/Grid.svelte`)

  • `loadStarted` flag prevents the mount `$effect` from re-running once it has kicked off `loadGrid`.
  • `startGame` returns early if it's already in flight (`if (starting) return;`).

Both layers are needed: the front fix removes the actual double-fire, the backend fix is a safety net for any future caller that double-tap-races.

Checklist

  • Conventional commit titles
  • No mention of AI / Claude / Anthropic / Copilot
  • `cargo fmt --check` passes
  • `cargo clippy --all-targets -- -D warnings` passes
  • `cargo test -p kalidoku-server --lib` passes (35 tests)
  • `pnpm --dir web lint && typecheck && test` passes
  • No new `unsafe`, no `unwrap()`
  • No new secret
  • No contract change

Test plan

  • Create a fresh duel via /duel, open the share link in the same browser → cells appear, no 500
  • Open the link a second time → resumes the in-flight game
  • Open a stale link of a finished game → 409 "already played" (not 500)
  • Open the link, observe DevTools Network → exactly one POST /games fires

…lation

A double-fire on POST /api/games from the same device (front mount
effect re-running, double-click, retry under flaky network) was racing
the SELECT-then-INSERT pattern and 500-ing with:

  duplicate key value violates unique constraint
  "uniq_games_grid_device"

When INSERT collides with the UNIQUE (grid_id, device_id) index, we
now re-read the winning row and return resume_existing on it — same
end-state as the pre-check branch, just resistant to the
SELECT-vs-INSERT window.

A finished game on the same (grid, device) still surfaces as 409
Conflict instead of resuming; the race fallback only resumes active
games. New helper is_unique_violation() pattern-matches the SeaORM
error on SQLSTATE 23505 or the index name.
The Svelte mount $effect was firing twice on duel links: loadGrid
reads store.state which is itself reactive, so the effect re-subscribes
each time startGame mutates the store. Without a guard, two POST
/games requests went out in the same tick, the second 500ing on the
UNIQUE (grid_id, device_id) DB constraint.

- loadStarted flag stops the $effect from re-running once it has
  kicked off loadGrid the first time.
- startGame returns early if it's already in flight (`starting`),
  defending against any other concurrent caller (retry click, abandon
  retry, etc.).

Backend is now race-tolerant too (resumes on UNIQUE violation), but
that's a safety net — the right fix is to not double-fire in the
first place.
@Calixteair Calixteair merged commit f1f0201 into main May 13, 2026
7 checks passed
@Calixteair Calixteair deleted the fix/start-game-race branch May 13, 2026 19:28
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