I am a Sr. Software Developer at Oracle Cloud. The opinions expressed here are my own and not necessarily those of my employer.
Redis NFL Leaderboard
In a previous post I discussed using Redis for Leaderboards. Let’s expand on these ideas. Recently at work we upgraded our fundraiser leaderboard and switched to use Redis as the data store with leaderboard gem.
But fundraising is not nearly as fun as football. My young son is a big fan of Seattle Seahawks so to explain to him what I do at work we built an NFL Leaderboard together. We made a great team because he knows football and I know coding.
- Core models
- Leaderboard specific Ruby classes
- Additonal team data
- Storing even more data in Redis
- Storing total_points counter in Redis
- Resetting data in the leaderboard
- Background job to simulate games
- Links
Core models
Basic models with Mongoid.
We wanted to make the leaderboard more sophisticated so we created LeaderboardGroup
which has has_and_belongs_to_many
relationship to Team
. This way many teams can complete in different groupings.
Then we created a simple callback from the Score
model:
Leaderboard specific Ruby classes
To encapsulate logic for accessing data in Redis Sorted Sets we created two Ruby classes (LeaderboardGet
and LeaderboardSet
) to wrap around leaderboard
gem (but we could have talked to Redis API directly).
team.total_points
is a method to get sum of all points for different scores (touchdowns, field goals, etc). rank_member_in
is a method provided by the leaderboard gem.
Data in Redis looks like this:
LeaderboardGet
class uses all_members_from
method from leaderboard
gem to extract data from Redis for JSON API output which looks like this: { rank: 1, score: 14, id: "team1_id", }, ...
Additonal team data
But all we have are team IDs which are not very useful for display. How can we get additonal information such as team names, logos, descriptions w/o having to query our primary DB? That data can be stored in Redis hashes. Fortunately the leaderboard
gem provides useful abstraction but underneath it are just regular Redis API calls.
Here we are adding just team names but same approach can be used for other attributes.
Redis hashes use team ID for the key and value is JSON encoded string of attributes. {"db":0,"key":"leaderboard:nfl-ldbr:member_data","ttl":-1,"type":"hash","value":{"team1_id":"{\"name\":\"washington-redskins\"}","team2_id":"{\"name\":\"arizona-cardinals\"}",...}
And JSON output looks like this { rank: 1, score: 14, id: "team1_id", name: "Arizona Cardinals"},...
Storing even more data in Redis
To make our Leaderboard even more interesting we wanted to keep the history of how the team rankings move up or down. So how can we store this data in Redis?
We decided to use different Sorted Sets (one for each team) where the key would use team ID with rank_history
appended to it. Members would be the positions in which the team was and scores would be times when team was in that position. This will give use chronologically sorted history of rank changes.
More updates to LeaderboardGet
and LeaderboardSet
These new data structures will look like this {"db":0,"key":"leaderboard:nfl-ldbr:team1_id:rank_history","ttl":-1,"type":"zset","value":[["7",1488739923.3964453],["8",1488739934.261501],["9",1488739939.2733278],["10",1488739942.9806864],["11",1488739944.2791183]],"size":96}
At the end of the set_rank_history
we fire update_last_rank_change
to update the Hash of team meta-data. So in the LeaderboardGet
we just need to format it properly for output. Now the combined API output looks like this { rank: 1, score: 14, id: "team1_id", name: "Arizona Cardinals", last_rank_change: "up" }, ...
Storing total_points counter in Redis
But we were not done. Since the scores were changing so fast we did not want to query the primary DB to calculate total_points
every time. We wanted to keep a counter in Redis during this time of high data volatility. redis-objects enables us to easily create methods on Team
model for such purpose.
We fire incr
and decr
calls when scores are created or destroyed right before we call LeaderboardSet
.
Data is stored like this {"db":0,"key":"team:team1_id:redis_total_points","ttl":-1,"type":"string","value":"14","size":1}
. RedisObjects creates a key based on model name, record ID and method name.
But you may be wondering what is perm_total_points
? We liked storing data in Redis when it was rapidly changing but we also wanted to preserve it in the main DB once the games were over. So we created another Ruby class to move the attributes.
Main appliation code simply uses team.total_points
method and the data is returned from either DB.
Resetting data in the leaderboard
But what if something happens to the data in Redis or there is a bug in our code? We need to have a way recreate these Sorted Sets and Hashes. For that we created LeaderboardReset
class.
These methods can be called from an internal dashboard or CLI when necessary.
Background job to simulate games
To make the demo exciting we built a simple UI using ReactJS with react-refetch and react-flip-move.
We also created a background job to create fake games and scores. It runs for 1 minute pausing 1 second between each score and all teams are playing at the same time.
You can view the leaderboard at https://nfl-leaderboard.herokuapp.com/ and play a few games if you want.
Links
- https://github.com/agoragames/leaderboard
- https://github.com/nateware/redis-objects
- https://github.com/redis/redis-rb
- https://github.com/resque/redis-namespace
- https://ruby-doc.org/stdlib-2.3.3/libdoc/set/rdoc/SortedSet.html
- http://www.nateware.com/real-time-leaderboards-with-elasticache-and-redis-objects.html
- https://redis.io/topics/distlock