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:

Verified: Stats Carry Into the Next Season
team:1:stats after one game in each of two seasons: {'played': '2', 'gf': '2', 'ga': '0', 'won': '2'}
Nothing is wrong with the script; it dutifully added to whatever hash it was given. A new season's table would start with last season's records already in it.

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:

season:{sid}:table # Chapter 7's sorted set season:{sid}:team:{tid}:stats # Chapter 7's per-team hash season:{sid}:totals # Chapter 8 season:{sid}:records season:{sid}:leaderboard
Verified: Only the Caller Changed
Both Lua scripts receive every key through 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:

await r.zrange("season:1:table", -3, -1) # the bottom three, e.g. ['004', '015', '005']

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:

async def season_complete(r, sid, games=38): members = await r.zrange(f"season:{sid}:table", 0, -1) async with r.pipeline(transaction=False) as pipe: for m in members: pipe.hget(f"season:{sid}:team:{int(m)}:stats", "played") played = await pipe.execute() return len(members) == 20 and all(int(p or 0) == games for p in played)
Verified on a Real 380-Fixture Season
after 50 fixtures: season_complete -> False played: [21, 21, 14, 3, 3, ...] after all 380: season_complete -> True played: [38, 38, 38, 38, 38, ...]

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:

ROLLOVER_LUA = """ if redis.call('EXISTS', KEYS[2]) == 1 then return -1 end -- this season was already rolled over local survivors = redis.call('ZRANGE', KEYS[1], 0, -4) -- everyone except the bottom three for _, mem in ipairs(survivors) do redis.call('SADD', KEYS[3], tostring(tonumber(mem))) -- '008' -> team id '8' redis.call('ZADD', KEYS[4], -500000, mem) -- zero-played score, as in Chapter 7 end redis.call('HSET', KEYS[5], 'name', ARGV[2]) redis.call('SET', KEYS[2], ARGV[1]) -- mark old season done: value = new season id redis.call('SET', 'season:current_id', ARGV[1]) return #survivors """ new_id = await r.incr("season:next_id") await rollover(keys=["season:1:table", "season:1:rolled", f"season:{new_id}:teams", f"season:{new_id}:table", f"season:{new_id}"], args=[new_id, "2025/26"])

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.

Verified: Two Concurrent Rollovers, One Wins
Two calls for the same finished season fired together with asyncio.gather:
results: [17, -1] # one rolled 17 clubs forward, the other was refused season:current_id -> 2 season:1:rolled -> 2 new season: 17 teams in the Set and 17 in the table relegated (season 1's last three): ['004', '015', '005'] -- none of them in the new season
The losing call had already taken season ID 3 from 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.
The Script Does Not Check Completeness
Only the route does. Called directly on a season with a single fixture entered, the script rolled 17 clubs forward and relegated whoever happened to be at the bottom of a one-game table (['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).

Verified: A Promoted Club Missing From the Table
After the rollover (17 clubs), adding three new team IDs with only Chapter 3's SADD:
SADD only: Set = 20 teams, table = 17 teams after new team 21's first result: table = 18 # teams 22 and 23 still absent adding ZADD for 22 and 23: table = 20
The Set says 20 clubs; the table shows 17. Chapter 7's script does 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:

Verified: What Survives a Hard Kill
default config, docker kill -> dbsize 0 season:current_id -> None default config, docker stop -> dbsize 2 season:current_id -> '7' # graceful shutdown saves a snapshot --appendonly yes --appendfsync always, docker kill -> dbsize 2 season:current_id -> '7'
Under the defaults, everything written since the last snapshot is lost on a crash — here, all of it. Turning on the append-only file with 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:

seasons = [k async for k in r.scan_iter(match="season:*:teams")] hits = [k for k in seasons if await r.sismember(k, "8")] # ['season:1:teams', 'season:2:teams'] -- correct

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.

NeedRelational siblingsThis course (Redis)
Ranked table, ranked leaderboardA query on each readBetter fit: maintained sorted set; read is one range call
Increment safely under concurrencyTransactions, or a SUMGood fit: atomic HINCRBY, scripts
Survive a crash by defaultYesNo — needs the append-only file turned on
Referential integrityForeign keysNone — checks written by hand
Ask a new question of old dataWrite a new queryUsually add a new structure and maintain it on every write
Fit in memoryNot a concernNot a concern here (about 170 KiB per season)
The Honest Answer to "Is Redis Ever the Right Fit Here?"
For the table and the leaderboard, yes — they are exactly what sorted sets are for, and both are derived data that can be rebuilt. As the only home for the fixtures and predictions, not without turning on persistence, and even then with integrity and new-question costs that a relational database gives for free. The design that fits this data best is often the two together: a durable database as the source of truth, with Redis holding the ranked views. Chapter 12 puts this variant side by side with the PostgreSQL one.

Hands-On Exercises

Exercise 1

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

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

Start 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 solution

Chapter 9 Quick Reference

  • Verified: team-keyed stats carry across seasons — scope every per-season key by season; scripts that take KEYS need 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 — SADD alone left a promoted club out of the table; also ZADD
  • Verified: default Redis loses everything since the last snapshot on a hard kill — the append-only file with fsync always survived
  • About 170 KiB per season — memory is not the constraint; durability, integrity and new-question cost are