Promotion & Relegation, and What Redis Alone Can't Model Cleanly
Premier League Predictor: FastAPI & Redis
Chapter 9 · Promotion & Relegation, and What Redis Alone Can't Model Cleanly
A season ends: the bottom three clubs go down, the other seventeen carry into the next season, and three newly promoted clubs are added by hand. The relegation half is genuinely a one-liner in Redis, thanks to Chapter 7's sorted set. But building the rollover exposes two mistakes in the key design of Chapters 7 and 8 that only show up once there is a second season — and the chapter then does what Chapter 1 promised, an honest accounting of what this variant costs.
A Bug the First Season Couldn't Show
Chapter 7 kept each team's stats in team:{id}:stats — keyed by team, not by season. That
works for exactly one season. Playing one game in season 1 and one in season 2, both with those keys:
The fix is to put the season in the key. Chapter 8's totals, records and
leaderboard have the same flaw and the same fix:
KEYS (the Chapter 7 tip about not building
key names inside a script), so neither script needed editing — only the routes that call them build
different key names. With season-scoped keys the same two games give played = 1 in each
season, and running Chapter 8's script for two seasons kept the two totals hashes separate.
Fixtures, predictions and per-fixture points stay unscoped: a fixture ID is already unique across
seasons.
Relegation: One Call
Chapter 7's table is ordered best first, so the relegated clubs are simply the last three:
That is only meaningful once the season is over. A table half-way through a season ranks clubs on a few games each. The route should refuse to relegate anyone until every team has played all 38 games:
The Rollover, in One Atomic Script
Carrying the survivors forward is several writes that must happen together: create the new season, copy seventeen teams into its membership set, seed each at zero in its table, mark the old season as rolled over, and make the new one current. As in earlier chapters, a Lua script makes that a single unit — and gives a double-click on "rollover" nothing to double:
The route calls season_complete first and returns 409 if it is false, then takes a new
season ID from INCR as in Chapter 2, and passes the script the keys it needs.
asyncio.gather:
INCR, so ID 3 is never used. A gap in
season numbering is harmless; the alternative — claiming the flag before taking an ID — would leave a
half-finished rollover if the process died in between.
['019', '020', '002']). The script also names one key, season:current_id,
inside itself rather than receiving it in KEYS — fine on a single server, the same
Cluster caveat as Chapter 7. Exercise 2 reproduces the first.
Relegation Deletes Nothing
Removing a club from the next season is an omission, not a deletion. After the rollover, season 1
still has 20 teams in its Set and 20 in its table, and every team:{id} hash is untouched.
The relegated clubs' history is intact and can come back if they are promoted later. The same design
choice as the relational siblings, and simpler here because a Set membership is just a list of IDs.
The Three Promoted Clubs
Which clubs are promoted is real-world knowledge no query here could produce, so — as in every
sibling — the three are entered by hand through Chapter 3's add-team route, which also enforces the
20-team cap with SCARD. That route needs one change. Season membership now lives in
two structures: the Set (Chapter 3) and the table (Chapter 7).
SADD:
ZADD a team the first
time it plays, so the table heals itself one club at a time — but until then a promoted club is simply
not listed. The add-team route must ZADD the member to the table in the same step as the
SADD.
This is the theme of the whole variant showing up once more: where a relational database has one
season_teams row that every query reads, this design has two structures that the code
must keep in agreement.
What Redis Alone Can't Model Cleanly Here
Chapter 1 asked to be taken on trust that this course was deliberately narrower than its PostgreSQL sibling. Nine chapters of evidence can now be added up. Each item below was checked, not assumed.
1. Durability — the one that matters most
A default Redis server keeps data in memory and only occasionally writes a snapshot. Checked against
the standard image: save = 3600 1 300 100 60 10000 (snapshot after an hour if at least
one key changed, after five minutes if 100 changed, after one minute if 10,000 changed) and
appendonly = no. Two keys were written, then the container was killed abruptly, as in a
crash or power loss:
appendfsync always kept both writes through the same
hard kill, at the cost of an fsync per write (there is a cheaper setting in between that risks a
short window of writes; this chapter didn't test it).
For this app the stakes are specific. The relegation-table and leaderboard are derived and can be rebuilt (Chapter 7 showed that). But the fixtures and the predictions are hand-typed source data — there is nothing to recompute them from. Running this variant as the only copy means either enabling the append-only file or accepting that a crash means re-entering data.
2. Size is not the problem
The usual worry about an in-memory store is fitting the data. Ten seasons of 380 fixtures each — 3,800 fixtures, each with five predictions, scored, with all the tables, stats, totals and leaderboards — came to 15,450 keys and about 1.7 MiB (roughly 170 KiB per season, growing steadily). That fits in memory many thousands of times over.
3. A second axis needs a second structure
"Which seasons has team 8 played in?" has no direct answer, because membership is stored season → teams, not team → seasons. The workable options are a scan or a second index:
It works, and for a few seasons it is instant, but SCAN walks the keyspace rather than
using an index. The alternative is a team:{id}:seasons Set that the add-team route also
maintains — which is the same "two structures to keep in agreement" cost as the promoted-club bug above.
4. Integrity lives entirely in the application
Across the course, a fixture could reference a team that doesn't exist (Chapter 2), a prediction or a result could be written for a fixture that doesn't exist (Chapters 5 and 6), and a hash could be silently merged into by a reused ID (Chapter 2). Every guard was a Lua script or a route check the author had to remember to write.
| Need | Relational siblings | This course (Redis) |
|---|---|---|
| Ranked table, ranked leaderboard | A query on each read | Better fit: maintained sorted set; read is one range call |
| Increment safely under concurrency | Transactions, or a SUM | Good fit: atomic HINCRBY, scripts |
| Survive a crash by default | Yes | No — needs the append-only file turned on |
| Referential integrity | Foreign keys | None — checks written by hand |
| Ask a new question of old data | Write a new query | Usually add a new structure and maintain it on every write |
| Fit in memory | Not a concern | Not a concern here (about 170 KiB per season) |
Hands-On Exercises
Enter one 1-0 result in each of two seasons using team-keyed stats hashes, report team 1's stats, then repeat with season-scoped stats keys and report each season's played count. State what changed in the script (nothing) and what changed in the caller.
📄 View solutionCall the rollover script directly on a 20-team season with only one fixture entered, and report which clubs it relegates. Then call it a second time and report the result and what happened to the season ID that call took.
📄 View solutionStart a default Redis container and one with the append-only file and fsync always, write two keys to each, kill both with docker kill, restart them and count the keys. Then repeat the default one with docker stop. Report all three results and say which restart behaviour to rely on for hand-entered fixtures.
📄 View solutionChapter 9 Quick Reference
- Verified: team-keyed stats carry across seasons — scope every per-season key by season; scripts that take
KEYSneed no change - Relegation is
ZRANGE table -3 -1— but only after every team has played 38 - Rollover is one atomic script — survivors =
ZRANGE 0 -4; a rolled-over flag makes a second call return -1 (verified:[17, -1]) - Relegation deletes nothing — the old season's Set and table stay intact
- Verified: two structures, one membership —
SADDalone left a promoted club out of the table; alsoZADD - Verified: default Redis loses everything since the last snapshot on a hard kill — the append-only file with
fsync alwayssurvived - About 170 KiB per season — memory is not the constraint; durability, integrity and new-question cost are