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.

Arsenal
Chelsea
Liverpool
Everton
Fulham
…

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:

gameweek:{season_id}:{number}:used_teams # a Set of team IDs

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":

# the naive, unguarded version async def naive_pair_team(r, gw_key, team_id): used = await r.sismember(gw_key, team_id) if used: return "REJECTED" await r.sadd(gw_key, team_id) return "ACCEPTED"
A Real, Reproduced Race — Not a Hypothetical One
Firing two real, genuinely concurrent calls to naive_pair_team for the exact same team, against the exact same gameweek:
results = await asyncio.gather( naive_pair_team(r, gw_key, "5"), # "Fixture A" naive_pair_team(r, gw_key, "5"), # "Fixture B" )
Fixture A (naive): ACCEPTED (team 5 paired) Fixture B (naive): ACCEPTED (team 5 paired) Real final used_teams set: {'5'} # only ONE member -- but BOTH requests were told "ACCEPTED"
This is the sharp part: the Set itself ends up looking perfectly fine — a single, correct member. The real bug is invisible if you only inspect the Set afterward. What actually went wrong is that two separate fixture records got created, both genuinely believing they'd legitimately claimed team 5, because both requests read 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.

from redis.exceptions import WatchError async def guarded_pair_team(r, gw_key, team_id): async with r.pipeline(transaction=True) as pipe: while True: try: await pipe.watch(gw_key) used = await pipe.sismember(gw_key, team_id) if used: await pipe.unwatch() return "REJECTED" pipe.multi() pipe.sadd(gw_key, team_id) await pipe.execute() return "ACCEPTED" except WatchError: continue # loop back and RE-CHECK -- see the warning below
Verified: The Race Is Closed
Re-running the identical two-concurrent-request scenario through the guarded version:
Fixture A (guarded): ACCEPTED (team 5 paired) Fixture B (guarded): REJECTED (team 5 already used) Real final used_teams set: {'5'} # exactly one member, exactly one real acceptance
Exactly one of the two requests wins now — the other is honestly, correctly told no.
A Genuinely Non-Textbook Nuance — Worth Getting Right
Most real 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

from fastapi.responses import JSONResponse @app.post("/api/gameweeks/{season_id}/{number}/fixtures") async def create_fixture(season_id: int, number: int, home_team_id: int, away_team_id: int): r = app.state.redis gw_key = f"gameweek:{season_id}:{number}:used_teams" home_result = await guarded_pair_team(r, gw_key, home_team_id) if home_result == "REJECTED": return JSONResponse({"error": f"team {home_team_id} already used this gameweek"}, status_code=400) away_result = await guarded_pair_team(r, gw_key, away_team_id) if away_result == "REJECTED": await r.srem(gw_key, home_team_id) # give the home team back: no fixture was created return JSONResponse({"error": f"team {away_team_id} already used this gameweek"}, status_code=400) fixture_id = await r.incr("fixture:next_id") await r.hset(f"fixture:{fixture_id}", mapping={ "home_team_id": home_team_id, "away_team_id": away_team_id, "gameweek": number, }) await r.sadd(f"gameweek:{season_id}:{number}:fixtures", fixture_id) return {"fixture_id": fixture_id}
Two Bugs in an Earlier Version of This Route
An earlier version of this route had two flaws, both found by calling it through a real test client. 1. The status code was never sent. Returning a tuple like ({...}, 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.

StackHow "no team twice in a gameweek" is actually enforced
PostgreSQL siblingA real server-side guard query checking every existing fixture in the gameweek before insert
SQLite siblingA 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

Exercise 1

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 solution
Exercise 2

Run 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 solution
Exercise 3

Explain, 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 solution

Chapter 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