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

Basic models with Mongoid.

# app/models/*
class Division
  field :conference
  field :title
  has_many :teams
end
class Team
  field :name
  has_many :away_games, class_name: 'Game', inverse_of: :away_team
  has_many :home_games, class_name: 'Game', inverse_of: :home_team
  has_many :scores
  belongs_to :division
end
class Game
  belongs_to :home_team, class_name: 'Team', inverse_of: :home_games
  belongs_to :away_team, class_name: 'Team', inverse_of: :away_games
  has_many :scores
end
class Score
  belongs_to :game
  belongs_to :team
end

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.

class LeaderboardGroup
  field :name
  has_and_belongs_to_many :teams
end
class Team
  has_and_belongs_to_many :leaderboard_groups
end

Then we created a simple callback from the Score model:

class Score
...
  after_create  do
    LeaderboardSet.new(self).perform
  end
end

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).

# config/initializers/redis.rb
NFL_LDBR = Leaderboard.new
# app/services/leaderboard_set.rb
class LeaderboardSet
  def initialize score
    @member = score.team.id
    @score = score.team.total_points
    @leaderboard_groups = score.team.leaderboard_groups
  end
  def perform
    # loop through all groups
    @leaderboard_groups.each do |leaderboard_group|
      process_leaderboard(leaderboard_group)
    end
  end
private
  def process_leaderboard leaderboard_group
    leaderboard_name = leaderboard_group.id
    NFL_LDBR.rank_member_in(leaderboard_name, @member, @score)
  end
end

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:

{"db":0,"key":"leaderboard_id1:","ttl":-1,"type":"zset","value":
  [["team1_id",14.0], ["team2_id",6.0]]...}

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", }, ...

class LeaderboardGet
  def initialize leaderboard_name: nil
    @leaderboard_name = leaderboard_name
  end
  def perform
    teams = NFL_LDBR.all_members_from(@leaderboard_name, with_member_data: true)
  end
end

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.

class LeaderboardSet
...
private
  def process_leaderboard leaderboard_group
    leaderboard_name = leaderboard_group.id
    member_data = {name: team.name}
    NFL_LDBR.rank_member_in(leaderboard_name, @member, @score, member_data)
  end
end
class LeaderboardGet
...
  def perform
    teams = NFL_LDBR.all_members_from(@leaderboard_name, with_member_data: true)
    teams.each do |hash|
      # format data
      if hash[:member_data].present?
        member_data = JSON.parse(hash.delete(:member_data))
        hash[:name] = member_data['name']
      end
    end
    return teams
  end
end

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

class LeaderboardSet
  def process_leaderboard leaderboard_group
    ...
    NFL_LDBR.rank_member_in(leaderboard_name, @member, @score, member_data)
    set_rank_history leaderboard_name
  end
  def set_rank_history leaderboard_name
    # => loop through all members in leaderboard set
    all_leaders = NFL_LDBR.all_leaders_from(leaderboard_name, members_only: false)
    all_leaders.each do |leader|
      # create separate SortedSet record where key = leaderboard_name + member
      # member = leaderboard rank, score = timestamp
      rank_history_zset = [leaderboard_name, leader[:member], 'rank_history'].join(':')
      member = leader[:rank]
      score = Time.now.to_f
      NFL_LDBR.rank_member_in(rank_history_zset, member, score)
      update_last_rank_change(leader[:member], leaderboard_name)
    end
  end
  def update_last_rank_change(member, leaderboard_name)
    last_rank_change = {last_rank_change:
      get_last_rank_change(member, leaderboard_name)}.to_json
    NFL_LDBR.update_member_data_in(leaderboard_name, member, last_rank_change)
  end
  # whether member moved up or down on last re-ranking in specific leaderboard
  def get_last_rank_change member, leaderboard_name
    rank_history_zset = [leaderboard_name, member, 'rank_history'].join(':')
    rank_history = NFL_LDBR.all_members_from(rank_history_zset,
      members_only: true).map(&:values).flatten
    if rank_history.first and rank_history.second
      if rank_history.first.to_i < rank_history.second.to_i
        return 'up'
      elsif rank_history.first.to_i > rank_history.second.to_i
        return 'down'
      else
        return ''
      end
    end
  end
end

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.

class Team
  ...
  include Redis::Objects
  counter :redis_total_points
  # => permanently store total_points after the games are over
  field :perm_total_points
  def total_points
    redis_total_points.value || perm_total_points
  end
end

We fire incr and decr calls when scores are created or destroyed right before we call LeaderboardSet.

class Score
  ...
  after_create  do update_redis('create')  end
  after_destroy do update_redis('destroy')  end
  def update_redis action
    if action == 'create'
      team.redis_total_points.incr(score_points)
    elsif action == 'destroy'
      team.redis_total_points.decr(score_points)
    end
    LeaderboardSet.new(self).perform
  end
end

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.

class RedisSync
  # => move data from Redis to main DB
  def sync_total_points
    Team.all.each do |team|
      current_value = team.redis_total_points.value
      team.update(perm_total_points: current_value)
      team.redis_total_points.decrement(current_value)
    end
  end
end

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.

class LeaderboardReset
  def reset_all_stats
    LeaderboardGroup.all.no_timeout.each do |leaderboard_group|
      reset_stats leaderboard_group
    end
  end
  def reset_stats leaderboard_group
    delete_stats leaderboard_group
    leaderboard_group.teams.no_timeout.each do |team|
      LeaderboardSet.new(team.scores.last).perform
    end
  end
  def delete_all_stats
    LeaderboardGroup.all.no_timeout.each do |leaderboard_group|
      delete_stats leaderboard_group
    end
  end
  def delete_stats leaderboard_group
    NFL_LDBR.delete_leaderboard_named leaderboard_group.id
  end
end

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.

class ScoresJob < ApplicationJob
  before_perform do
    remove_previous_data
    create_games
  end
  after_perform do
    RedisSync.new.sync_total_points
  end
  def perform
    60.times do |i|
      create_score
      sleep 1
    end
  end
private
  def remove_previous_data
    ...
  end
  def create_games
    ...
  end
  def create_score
    ...
  end

end

You can view the leaderboard at https://nfl-leaderboard.herokuapp.com/ and play a few games if you want.