We at Lumosity love to improve. Personal and professional development are as important as the product we build each and every day. With that expectation for improvement comes radical new paradigm shifts on core systems. This time it was on our Brain Performance Index (BPI) system. BPI is a scale that allows users to see how well they are doing in the five core cognitive areas Lumosity is designed to help train. Every game on Lumosity falls into one of those cognitive areas and each game ends with a score. That score is then used to calculate a BPI for that game, which is then fed into that game’s area BPI, which then feeds into the user’s overall BPI.

Late last year our science team began devising a new system for calculating BPI that was more responsive to each game play a user completed and could scale out to more games across our multiple platforms (web, iOS and soon Android). This new system was named the “Lumosity Performance Index” (LPI) and with it would come a new set of calculators that could transform a game play’s score to an LPI and also update a variety of other stats for that user, including the game’s area LPI and the user’s overall LPI.

Once the new system and calculators were built, we needed to build a way to migrate or backpopulate existing game plays’ scores to LPI. At the time of this writing, we have over 60 million registered users who have played more than 1.6 billion games, and it’s growing quickly each day

The migrator script, version 1

Because LPI at any given moment in time is calculated as the result of all previous game plays up to that moment, migrating users entailed replaying the game play history of every registered user in order.

We used our Scripterator gem for this task, and came up with something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
require 'timecop'

Scripterator.run "Backpopulate LPI data for users" do
  for_each_user do |user|
    game_plays = GamePlays.for_user(user).order('created_at ASC')

    game_plays.each do |game_play|
      Timecop.travel(game_play.created_at) do
        lpi_game_play = calculate_lpi(game_play)
        lpi_game_play.save!
        update_area_lpi_for_game_play(game_play)
        update_overall_lpi_for_game_play(game_play)
        update_game_play_stats(game_play)
      end
    end
    user.grant_role(:lpi)
    true
  end
end

This was a pretty simple script that did all that we needed it to. So we began to run it on users with varying numbers of game plays and ended up with an average processing time of 0.2 seconds per game play. It didn’t seem so bad until we realized that would mean that, unparallelized, this script would take 9.3 years to complete! And with the incredible amount of new game plays we get each day, we’d never catch up. So we thought, “Hey, let’s parallelize it across multiple workers!” Even then, across 100 workers, it would take over a month to complete – far too slow.

We took a look at all the logs that were output from running this migrator script on a single user, and saw that for about 400 game plays, we were making over 50,000 network calls (MySQL, Memcache, Redis)! That was unsustainable, and probably a big part of where our slow down was coming from.

The migrator script, version 2

The first thing we needed to eliminate was all those network calls, and that meant putting more shared data into RAM. What we came up with were multiple ‘RAM stores’ that would replace ActiveRecord calls during the processing of each game play for a user. The goal was to reduce the network queries per game play to 0, and then do the saving/updating after we were done with all the user’s data and wanted to move onto the next user to be migrated.

An example RAM store for our games table and one to store each new LPI game play for a user:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# ram_stores.rb

class GameStore
  def self.seed @games ||= Game.all.to_a @games_hash = {}
  end

  def self.games
    @games
  end

  def self.find(id)
    @games_hash[id] ||= games.find { |g| g.id == id }
  end

  def self.find_by_bac_ids(area_ids)
    @games.select { |g| area_ids.include?(g.brain_area_id) }
  end
end

class LpiGamePlayStore
  def self.reset
    @lpi_game_plays = []
  end

  def self.lpi_game_plays
    @lpi_game_plays ||= []
  end

  def self.add(game_play)
    game_play.created_at = Time.now
    lpi_game_plays << game_play
  end
end

We had to build eight stores in all to cover all the models that used to call out to ActiveRecord to get or store data. But the stores by themselves were not enough: we needed to use them. Instead of building new models and calculators, we instead just opened up our models and redefined a few methods here or there.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# lpi_overrides.rb
class LpiGamePlay < ActiveRecord::Base
  def update_play_count
    count          = GameStatStore.find(game_id).try(:play_count) || 0
    count          += 1
    GameStatStore.set_for_game(game_id, count)
  end

  def recalculate_game_lpi(user)
    calc = LpiForGameCalculator.new(user, lpi_game_play: self, score: score)
    self.lpi = set_lpi_nil ? nil : calc.calculate
  end
end

class LpiForGameCalculator < GameCalculatorBase
  def initialize(user, attrs)
    super(user)
    @lpi_game_play   = attrs[:lpi_game_play]
    @game_id         = @lpi_game_play.try(:game_id) || attrs[:game_id]
    @game            = GameStore.find(@game_id)
    @score           = attrs[:score]
  end

  def calculate
    return nil unless lpi_game_play.present? && game_has_percentiles?
    return lpi_game_play.lpi if lpi_game_play.lpi.present?

    past_lpi_data  = past_lpi_lookup
    last_lpi       = past_lpi_data[:last]
    new_result_lpi = lpi_for_game_score

    new_lpi = if past_lpi_data[:count] < 3 || last_lpi == 0
      [last_lpi, new_result_lpi].max
    else
      new_lpi_for(last_lpi, new_result_lpi)
    end.to_i

    store_game_lpi(new_lpi)
    new_lpi
  end

  protected

  def past_lpi_lookup
    last_lpi = GameLpiStore.for_game(game.game_id).try(:lpi) || 0
    count    = GameStatStore.for_game_id(game.id).try(:play_count) || 0
    { count: count, last: last_lpi }
  end

  def fetch_percentiles_table
    GameLpiPercentile.get_table_for(game_id)
  end

  def store_game_lpi(new_lpi)
    GameLpiStore.set_for_game(game.id, new_lpi)
  end
end

We ended up opening up 11 of our classes to redefine about 20 methods so that they used the RAM stores, and not ActiveRecord. Our migrator script was responsible for requiring both the ram_stores.rb and the lpi_overrides.rb.

The updated migration script looked a bit like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
require 'timecop'
require 'ram_stores'
require 'lpi_overrides'

PercentileStore.seed
GameStore.seed
BrainAttributeCategoryStore.seed

Scripterator.run "Backpopulate LPI data for users" do
  for_each_user do |user|
    GameStatStore.reset
    LpiGamePlayStore.reset
    GameLpiStore.reset(user)
    DailyLpiStore.reset(user)
    UserStore.set_user(user)

    game_plays = GamePlay.where(user_id: user.id).order('created_at ASC')

    game_plays.each do |gr|
      Timecop.travel(gr.created_at) do
        lpi_game_play = calculate_lpi(game_play)
        LpiGamePlayStore.add(lpi_game_play)

        # All updated to store to a RAM store
        update_area_lpi_for_game_play(game_play)
        update_overall_lpi_for_game_play(game_play)
        update_game_play_stats(game_play)
      end
    end

    # Store to DB with bulk-inserts
    LpiGamePlay.import!(LpiGamePlayStore.lpi_game_plays)
    GameStat.import!(GameStatStore.stats)
    GameLpi.import!(GameLpiStore.lpis)
    OverallLpi.import!(OverallLpiStore.lpis)
    AreaLpi.import!(AreaLpiStore.lpis)

    true
  end
end

Results

With the replacing of ActiveRecord, Memcache, and Redis calls to use these RAM stores, our per-game-play processing time went from 0.2s down to as low as 0.007s! Taking the total time from 9.3 years (unparallelized) to about 4 months (~128 days, unparallelized) or about 2 days if we parallelized it across 100 workers. Success!