The Fast Fixture-Entry UI
Premier League Predictor: FastAPI & Redis
Chapter 4 · The Fast Fixture-Entry UI
Chapter 3 closed with a promise: Redis's own real WATCH-based optimistic locking would
get covered honestly, once this course actually needed it. Building the real fixture-entry
interface — the fast, click-to-pair UI the shared spec calls for — is exactly where that need shows
up, in a genuine, reproducible race condition rather than a hypothetical one.
The Real UI: 20 Buttons, No Dropdowns
Matching this project's own real client precedent — a plain vanilla-JS/HTML/CSS frontend, the same choice already made for the PostgreSQL sibling — the fixture-entry page shows all 20 of a season's own teams as always-visible buttons. Pairing two teams into a fixture is two clicks: the home team, then the away team.
A team already paired into a fixture this gameweek is shown struck through and disabled — a real, working piece of UX, but this chapter's own central finding is about exactly how far that client-side disabling can be trusted.
Modeling "Already Used" for This Gameweek
Alongside gameweek:{season_id}:{number}:fixtures from Chapter 2, this chapter adds one
more real Set — every team ID already paired into a fixture this gameweek:
Checking whether a team can still be paired is a single, cheap SISMEMBER call — real,
idiomatic Redis, and the obvious first attempt at enforcing "no team twice in a gameweek":
naive_pair_team for the exact same
team, against the exact same gameweek:
SISMEMBER as False
before either one had written anything. The gap between the check and the write — a real,
ordinary window, not a contrived one — is exactly where two near-simultaneous clicks on the real UI
could land.
The Real Fix: WATCH, MULTI, EXEC
Redis's own real WATCH command monitors a key for changes between the moment it's
watched and the moment a queued transaction actually runs. If the watched key changed in that
window, EXEC is refused entirely and the client library raises a real
WatchError — the transaction never partially applies, it simply never runs at all.
WATCH examples (Redis's own official documentation included) show a retry
loop built around incrementing a counter — an operation where retrying and blindly re-running the
exact same write is always correct, since the second attempt just adds 1 to whatever the value now
is. This chapter's own real rule is a genuinely different shape: first writer wins, every
later attempt must be rejected, not retried into eventually succeeding. That's exactly why
the except WatchError: continue branch loops back to SISMEMBER again
rather than jumping straight to re-queuing the SADD — on the second pass, the check
itself now correctly sees the team as used, and the function returns "REJECTED"
honestly, instead of a naive retry-the-write loop silently creating a second, illegitimate
acceptance.
Wiring This Into a Real Route
({...}, 400)
is Flask's convention, not FastAPI's. FastAPI serialized the tuple as a JSON array and answered
HTTP 200 with [{"error": ...}, 400], so a client checking the status code
saw success. JSONResponse(..., status_code=400) sends the real status.
2. A rejected pairing still kept the home team. The home team was claimed first; if
the away team was then rejected, the route returned an error but left the home team in the used set,
so a later, valid fixture using that team was wrongly refused. The srem line gives it
back. In the test, team 3 was rejected as "already used" after a failed pairing before the fix and
accepted after it.
One caution: the two claims are separate steps, so another request can briefly see the home team as
used between the claim and the give-back. Making the pair a single atomic step is possible in Redis,
but this chapter's guard works one team at a time.
The Client-Side Button State Is UX, Not Authority
The real UI disables an already-used team's own button client-side — real, immediate feedback with no round trip needed. But that's convenience only. The actual decision of whether a pairing is allowed is made exactly once, server-side, by the guarded check this chapter built — the same honest split this project's own PostgreSQL sibling already established with its own real, server-side duplicate-team guard.
| Stack | How "no team twice in a gameweek" is actually enforced |
|---|---|
| PostgreSQL sibling | A real server-side guard query checking every existing fixture in the gameweek before insert |
| SQLite sibling | A real UNIQUE constraint, with a real SqliteError.code caught and translated into a friendly response |
| This course (Redis) | Verified: a WATCH-guarded SISMEMBER check, re-validated on every retry rather than assumed safe after one read |
Hands-On Exercises
Modify this chapter's own except WatchError: continue branch so it re-queues the SADD directly instead of looping back to re-check SISMEMBER (i.e. treat it like the textbook counter-retry pattern). Run the real two-concurrent-request scenario against this version and report what actually happens.
📄 View solutionRun three genuinely concurrent calls to guarded_pair_team for the same team and gameweek, not just two. Report the real result for all three, and explain why WATCH-based retrying scales correctly to more than two concurrent requests without any extra code.
📄 View solutionExplain, in your own words, why this chapter's own race condition could never be fixed by wrapping the naive SISMEMBER-then-SADD sequence in an ordinary MULTI/EXEC transaction with no WATCH at all. What specifically does WATCH add that plain MULTI/EXEC doesn't have?
📄 View solutionChapter 4 Quick Reference
- 20 always-visible team buttons — click-to-pair, matching this project's own established fast-entry UI
- Verified: a real check-then-act race — two concurrent requests both accepted the same team into a gameweek
- WATCH/MULTI/EXEC closes it — verified: exactly one request wins, the other is honestly rejected
- A genuinely non-textbook nuance — the retry loop re-validates the condition rather than blindly retrying the write, since this is a first-writer-wins rule, not an idempotent counter
- Client-side disabling is UX, not authority — the same honest split already established by this project's own PostgreSQL sibling