Admin — Managing Teams & Seasons

Premier League Predictor: FastAPI & Redis

Chapter 3 · Admin: Managing Teams & Seasons

Chapter 2 modeled teams, fixtures, and their real membership relationships. This chapter builds the real admin routes that actually create and manage them — and finds both a genuine simplicity win for Redis and a sharper, more surprising limitation than anything Chapter 2 turned up.

The Current Season: One Key, Not a Cross-Row Clear

This project's own real PostgreSQL and SQLite siblings both had to solve the same problem: only one season is ever "current" at a time, so promoting a new one to current means clearing the flag on every other row first. In Redis, that whole problem simply doesn't exist, because there's no is_current field sitting on individual season records at all — there's one dedicated key holding the current season's own ID directly:

await r.set("season:current_id", 1) # season:current_id -> "1" # switching which season is current is a single write -- nothing else to touch await r.set("season:current_id", 2) # season:current_id -> "2"
A Genuine Simplicity Win, Not Just a Cost
There's no second row anywhere that could be left in a stale, half-updated state — there's only ever one value in the one place the app actually checks. Where a relational sibling needs the real guarantee that clearing every other row's own flag and setting the new one happens as a single atomic operation, this version's "atomicity" is a free, structural consequence of there being only one write to make in the first place. Chapter 2 found two real costs of this variant's own choices; this is the first genuine case where the same choice pays off instead.

Creating Teams: A Real Secondary Index, Not a UNIQUE Constraint

The PostgreSQL sibling can lean on a real UNIQUE constraint on a team's own name column to guarantee a relegated-then-promoted club is reused as the same permanent record, never accidentally duplicated. Redis hashes have no such thing — nothing stops two different team keys from both holding name: "Arsenal". The real, idiomatic fix is a manually-maintained secondary index: a second hash mapping name directly to ID.

team_id = await r.incr("team:next_id") await r.hset(f"team:{team_id}", mapping={"name": "Arsenal", "founded": 1886}) await r.hset("team:by_name", "Arsenal", team_id) # a real reverse lookup, used before creating a new team existing_id = await r.hget("team:by_name", "Arsenal") # existing_id -> "1" not_found = await r.hget("team:by_name", "Nonexistent FC") # not_found -> None

A real create-team route checks this index first, and only creates a genuinely new hash and counter entry if nothing is found:

@app.post("/api/teams") async def create_team(name: str, founded: int): r = app.state.redis existing_id = await r.hget("team:by_name", name) if existing_id: return {"team_id": int(existing_id), "reused": True} team_id = await r.incr("team:next_id") await r.hset(f"team:{team_id}", mapping={"name": name, "founded": founded}) await r.hset("team:by_name", name, team_id) return {"team_id": team_id, "reused": False}
A Real, Ongoing Maintenance Cost
This index is entirely this app's own responsibility to keep correct — nothing in Redis enforces that team:by_name and every team:{id} hash agree with each other. A route that ever updates a team's own name directly, without also updating team:by_name, would leave the index silently pointing at the old name forever. A real SQL index on a name column would never need this kind of manual bookkeeping at all — it's maintained by the database itself, on every write, automatically.

The 20-Team Cap: SCARD, Not a CHECK Constraint

Adding a team to a season means adding its ID to that season's own membership Set — and the real 20-team limit has to be enforced by the application, since Redis Sets have no native maximum-size option:

@app.post("/api/seasons/{season_id}/teams/{team_id}") async def add_team_to_season(season_id: int, team_id: int): r = app.state.redis count = await r.scard(f"season:{season_id}:teams") if count >= 20: return {"error": "season already has 20 teams"}, 400 await r.sadd(f"season:{season_id}:teams", team_id) return {"added": team_id}

Verified against a real, freshly-populated season set:

season:1:teams size after adding 20: 20 App-level check: refusing to add a 21st team (count already at cap)

The Real Headline Finding: MULTI/EXEC Does Not Roll Back

This course's own real siblings each lean on a genuine atomicity guarantee for multi-step admin writes — SQLAlchemy's flush()-then-commit() pair, or a real db.transaction() wrapper — where, if anything inside fails, every earlier change in that same operation is undone as if it had never happened. Redis has its own real transaction mechanism, MULTI/EXEC — but Redis's own official documentation states its real guarantee directly: "Redis does not support rollbacks of transactions since supporting rollbacks would have a significant impact on the simplicity and performance of Redis." A command that fails inside a real Redis transaction doesn't undo the commands around it — "even when a command fails, all the other commands in the queue are processed."

Verified With Real Code — And More Surprising Than the Docs Alone Suggest
Setting up a real counter at 10 and a real Set (the wrong type for INCR), then queuing three commands in one real redis-py transaction — two valid increments around one command guaranteed to fail:
async with r.pipeline(transaction=True) as pipe: pipe.incr("counter:test") # valid pipe.incr("myset:test") # INVALID -- myset is a real Set, not a number pipe.incr("counter:test") # valid results = await pipe.execute()
pipe.execute() raised: ResponseError Command #2 (INCRBY myset:test 1) of pipeline caused error: ('WRONGTYPE Operation against a key holding the wrong kind of value',) counter:test AFTER the failed transaction: 12
The counter started at 10. It ended at 12 — both valid INCR calls genuinely took effect, permanently, despite the transaction as a whole raising a real Python exception. This is the sharpest, most easily-misread part of the finding: redis-py's own pipe.execute() genuinely raises an error, which reads exactly like the kind of exception a SQL transaction context manager raises specifically to signal "nothing was committed" — but here, raising an exception and rolling back are two completely unrelated things. The exception just reports that one command failed; it says nothing about the fate of the others, because Redis never undid them in the first place.
StackOn a mid-transaction failure
PostgreSQL / SQLAlchemy siblingEvery change in the same flush/commit is undone — a genuine all-or-nothing guarantee
SQLite / better-sqlite3 siblingThe real db.transaction() wrapper rolls back every change on any thrown error
This course (Redis)Verified: earlier successful commands in the same MULTI/EXEC permanently survive; only the one failing command is skipped
The Real, Honest Design Response
Given this, every admin route in this chapter is deliberately built around single, self-contained commands — one HSET, one SADD, one SET — rather than chaining several writes together inside one transaction and trusting a rollback that Redis genuinely doesn't provide. Where a real multi-step sequence is unavoidable, later chapters reach for Redis's own real WATCH-based optimistic locking instead, which aborts an entire transaction before any of it runs if a watched key changed — a genuinely different, and genuinely real, kind of safety net than rollback, covered honestly once this course actually needs it.

Hands-On Exercises

Exercise 1

Reproduce this chapter's own no-rollback finding with a genuinely different failing command -- queue a real HSET against a key that already holds a plain string (not a hash), sandwiched between two valid SADD calls on a real Set, inside one MULTI/EXEC transaction. Report whether the two valid SADD calls survive.

📄 View solution
Exercise 2

Create a real team, then simulate a route that renames it by calling HSET on team:{id} with a new name field -- WITHOUT updating team:by_name. Show, with real code, that the old name still resolves via the secondary index while the new name does not, and explain what a real user-facing symptom of this bug would look like.

📄 View solution
Exercise 3

Explain, in your own words, why a Python exception raised by pipe.execute() cannot be safely treated as "this transaction had no effect," using this chapter's own verified counter example as evidence. What real, different question does the exception actually answer?

📄 View solution

Chapter 3 Quick Reference

  • Current season = one key — a genuine simplicity win, no cross-row clearing needed at all
  • Team uniqueness needs a manual secondary index — team:by_name stands in for a real UNIQUE constraint, and the app owns keeping it in sync
  • The 20-team cap is a manual SCARD check — no native CHECK-constraint equivalent
  • Verified: MULTI/EXEC does not roll back — earlier successful commands in a failed transaction permanently survive
  • An exception from execute() ≠ nothing happened — it reports one command's own failure, not the fate of the whole transaction