Skip to content

Commit e30e986

Browse files
authored
rework lb generation (#467)
1 parent 2c8cfb5 commit e30e986

13 files changed

+297
-217
lines changed

app/controllers/leaderboards_controller.rb

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,35 +78,31 @@ def find_or_generate_leaderboard
7878
def generate_regional_leaderboard
7979
return nil unless current_user&.timezone_utc_offset
8080

81-
LeaderboardGenerator.generate_timezone_offset_leaderboard(
82-
start_date, current_user.timezone_utc_offset, @period_type
81+
LeaderboardService.get(
82+
period: @period_type,
83+
date: start_date,
84+
offset: current_user.timezone_utc_offset
8385
)
8486
end
8587

8688
def generate_timezone_leaderboard
8789
return nil unless current_user&.timezone
8890

89-
LeaderboardGenerator.generate_timezone_leaderboard(
90-
start_date, current_user.timezone, @period_type
91+
offset = current_user.timezone_utc_offset
92+
return nil unless offset
93+
94+
LeaderboardService.get(
95+
period: @period_type,
96+
date: start_date,
97+
offset: offset
9198
)
9299
end
93100

94101
def find_or_generate_global_leaderboard
95-
cache_key = "leaderboard_#{@period_type}_#{start_date}"
96-
97-
leaderboard = Rails.cache.fetch(cache_key, expires_in: 1.minute) do
98-
Leaderboard.where.not(finished_generating_at: nil)
99-
.find_by(start_date: start_date, period_type: @period_type, deleted_at: nil)
100-
end
101-
102-
Rails.cache.delete(cache_key) if leaderboard.nil?
103-
104-
if leaderboard.nil?
105-
LeaderboardUpdateJob.perform_later(@period_type)
106-
nil
107-
else
108-
leaderboard
109-
end
102+
LeaderboardService.get(
103+
period: @period_type,
104+
date: start_date
105+
)
110106
end
111107

112108
def start_date

app/controllers/static_pages_controller.rb

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -89,31 +89,23 @@ def mini_leaderboard
8989

9090
if use_timezone_leaderboard && current_user&.timezone_utc_offset
9191
# we now doing it by default wooo
92-
@leaderboard = LeaderboardGenerator.generate_timezone_offset_leaderboard(
93-
Date.current, current_user.timezone_utc_offset, :daily
92+
@leaderboard = LeaderboardService.get(
93+
period: :daily,
94+
date: Date.current,
95+
offset: current_user.timezone_utc_offset
9496
)
9597

9698
if @leaderboard&.entries&.empty?
9799
Rails.logger.warn "[MiniLeaderboard] Regional leaderboard empty for offset #{current_user.timezone_utc_offset}"
100+
@leaderboard = nil
98101
end
99-
else
100-
# Use global leaderboard
101-
@leaderboard = Leaderboard.where.associated(:entries)
102-
.where(start_date: Date.current)
103-
.where(deleted_at: nil)
104-
.where(period_type: :daily)
105-
.distinct
106-
.first
107102
end
108103

109-
if @leaderboard.nil? || @leaderboard.entries.empty?
110-
Rails.logger.info "[MiniLeaderboard] Falling back to global leaderboard"
111-
@leaderboard = Leaderboard.where.associated(:entries)
112-
.where(start_date: Date.current)
113-
.where(deleted_at: nil)
114-
.where(period_type: :daily)
115-
.distinct
116-
.first
104+
if @leaderboard.nil?
105+
@leaderboard = LeaderboardService.get(
106+
period: :daily,
107+
date: Date.current
108+
)
117109
end
118110

119111
@active_projects = Cache::ActiveProjectsJob.perform_now

app/jobs/leaderboard_update_job.rb

Lines changed: 79 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,111 @@
11
class LeaderboardUpdateJob < ApplicationJob
22
queue_as :latency_10s
33

4-
BATCH_SIZE = 1000
5-
64
include GoodJob::ActiveJobExtensions::Concurrency
75

8-
# Limits concurrency to 1 job per date
6+
# Limits concurrency to 1 job per period/date combination
97
good_job_control_concurrency_with(
10-
key: -> { "#{arguments[0] || 'daily'}_#{arguments[1] || Date.current.to_s}" },
8+
key: -> { "leaderboard_#{arguments[0] || 'daily'}_#{arguments[1] || Date.current.to_s}" },
119
total: 1,
1210
drop: true
1311
)
1412

15-
def perform(period_type = :daily, date = Date.current)
16-
parsed_date = date.is_a?(Date) ? date : Date.parse(date.to_s)
13+
def perform(period = :daily, date = Date.current)
14+
date = LeaderboardDateRange.normalize_date(date, period)
15+
16+
Rails.logger.info "Starting leaderboard generation for #{period} on #{date}"
17+
18+
board = build_global(date, period)
19+
build_timezones(date, period)
1720

18-
parsed_date = parsed_date.beginning_of_week if period_type == :weekly
21+
Rails.logger.info "Completed leaderboard generation for #{period} on #{date}"
22+
23+
board
24+
rescue => e
25+
Rails.logger.error "Failed to update leaderboard: #{e.message}"
26+
Honeybadger.notify(e, context: { period: period, date: date })
27+
raise
28+
end
1929

20-
leaderboard = Leaderboard.create!(
21-
start_date: parsed_date,
22-
period_type: period_type
23-
)
30+
private
2431

25-
date_range = case period_type
26-
when :weekly
27-
(parsed_date.beginning_of_day...(parsed_date + 7.days).beginning_of_day)
28-
when :last_7_days
29-
((parsed_date - 6.days).beginning_of_day...parsed_date.end_of_day)
30-
else
31-
parsed_date.all_day
32+
def build_global(date, period)
33+
range = LeaderboardDateRange.calculate(date, period)
34+
board = ::Leaderboard.find_or_create_by!(
35+
start_date: date,
36+
period_type: period,
37+
timezone_offset: nil
38+
) do |lb|
39+
lb.finished_generating_at = nil
3240
end
3341

34-
Rails.logger.info "Starting leaderboard generation for #{period_type} on #{parsed_date}"
42+
return board if board.finished_generating_at.present?
3543

3644
ActiveRecord::Base.transaction do
37-
entries_data = Heartbeat.where(time: date_range)
38-
.coding_only
39-
.with_valid_timestamps
40-
.joins(:user)
41-
.where.not(users: { github_uid: nil })
42-
.group(:user_id)
43-
.duration_seconds
45+
board.entries.delete_all
46+
data = Heartbeat.where(time: range)
47+
.with_valid_timestamps
48+
.joins(:user)
49+
.coding_only
50+
.where.not(users: { github_uid: nil })
51+
.group(:user_id)
52+
.duration_seconds
4453

45-
entries_data = entries_data.filter { |_, total_seconds| total_seconds > 60 }
54+
data = data.filter { |_, seconds| seconds > 60 }
4655

47-
convicted_users = User.where(trust_level: User.trust_levels[:red]).pluck(:id)
48-
entries_data = entries_data.reject { |user_id, _| convicted_users.include?(user_id) }
56+
convicted = User.where(trust_level: User.trust_levels[:red]).pluck(:id)
57+
data = data.reject { |user_id, _| convicted.include?(user_id) }
4958

50-
streaks = Heartbeat.daily_streaks_for_users(entries_data.keys)
59+
streaks = Heartbeat.daily_streaks_for_users(data.keys)
5160

52-
entries_data = entries_data.map do |user_id, total_seconds|
61+
entries = data.map do |user_id, seconds|
5362
{
54-
leaderboard_id: leaderboard.id,
63+
leaderboard_id: board.id,
5564
user_id: user_id,
56-
total_seconds: total_seconds,
57-
streak_count: streaks[user_id] || 0
65+
total_seconds: seconds,
66+
streak_count: streaks[user_id] || 0,
67+
created_at: Time.current,
68+
updated_at: Time.current
5869
}
5970
end
6071

61-
LeaderboardEntry.insert_all!(entries_data) if entries_data.any?
72+
LeaderboardEntry.insert_all!(entries) if entries.any?
73+
board.update!(finished_generating_at: Time.current)
6274
end
6375

64-
leaderboard.finished_generating_at = Time.current
65-
leaderboard.save!
76+
key = LeaderboardCache.global_key(period, date)
77+
LeaderboardCache.write(key, board)
6678

67-
Leaderboard.where.not(id: leaderboard.id)
68-
.where(start_date: parsed_date, period_type: period_type)
69-
.where(deleted_at: nil)
70-
.update_all(deleted_at: Time.current)
79+
board
80+
end
7181

72-
leaderboard
73-
rescue => e
74-
Rails.logger.error "Failed to update current leaderboard: #{e.message}"
75-
raise
76-
rescue Date::Error
77-
raise ArgumentError, "Invalid date format provided"
82+
def build_timezones(date, period)
83+
range = LeaderboardDateRange.calculate(date, period)
84+
85+
offsets = User.joins(:heartbeats)
86+
.where(heartbeats: { time: range })
87+
.where.not(timezone_utc_offset: nil)
88+
.distinct
89+
.pluck(:timezone_utc_offset)
90+
.compact
91+
92+
Rails.logger.info "Generating timezone leaderboards for #{offsets.size} active UTC offsets"
93+
94+
offsets.each do |offset|
95+
build_timezone(date, period, offset)
96+
end
97+
end
98+
99+
def build_timezone(date, period, offset)
100+
key = LeaderboardCache.timezone_key(offset, date, period)
101+
102+
data = LeaderboardCache.fetch(key) do
103+
users = User.users_in_timezone_offset(offset).not_convicted
104+
LeaderboardBuilder.build_for_users(users, date, "UTC#{offset >= 0 ? '+' : ''}#{offset}", period)
105+
end
106+
107+
Rails.logger.debug "Cached timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} with #{data&.entries&.size || 0} entries"
108+
109+
data
78110
end
79111
end

app/jobs/timezone_leaderboard_job.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
class TimezoneLeaderboardJob < ApplicationJob
2+
queue_as :latency_5m
3+
4+
include GoodJob::ActiveJobExtensions::Concurrency
5+
6+
# Limits concurrency to 1 job per timezone/period/date combination
7+
good_job_control_concurrency_with(
8+
key: -> { "timezone_#{arguments[0]}_#{arguments[1]}_#{arguments[2]}" },
9+
total: 1,
10+
drop: true
11+
)
12+
13+
def perform(period = :daily, date = Date.current, offset = 0)
14+
date = LeaderboardDateRange.normalize_date(date, period)
15+
16+
Rails.logger.info "Generating timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} (#{period}, #{date})"
17+
18+
key = LeaderboardCache.timezone_key(offset, date, period)
19+
20+
# Generate the leaderboard
21+
board = build_timezone(date, period, offset)
22+
23+
# Cache it for 10 minutes
24+
LeaderboardCache.write(key, board)
25+
26+
Rails.logger.info "Cached timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} with #{board&.entries&.size || 0} entries"
27+
28+
board
29+
rescue => e
30+
Rails.logger.error "Failed to generate timezone leaderboard for UTC#{offset}: #{e.message}"
31+
Honeybadger.notify(e, context: { period: period, date: date, offset: offset })
32+
raise
33+
end
34+
35+
private
36+
37+
def build_timezone(date, period, offset)
38+
users = User.users_in_timezone_offset(offset).not_convicted
39+
LeaderboardBuilder.build_for_users(users, date, "UTC#{offset >= 0 ? '+' : ''}#{offset}", period)
40+
end
41+
end

app/jobs/warm_mini_leaderboard_cache_job.rb

Lines changed: 0 additions & 20 deletions
This file was deleted.

app/services/leaderboard_builder.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
module LeaderboardBuilder
2+
module_function
3+
4+
def build_for_users(users, date, scope, period)
5+
date = Date.current if date.blank?
6+
7+
board = ::Leaderboard.new(
8+
start_date: date,
9+
period_type: period,
10+
finished_generating_at: Time.current
11+
)
12+
13+
ids = users.pluck(:id)
14+
return board if ids.empty?
15+
16+
users_map = users.index_by(&:id)
17+
18+
range = LeaderboardDateRange.calculate(date, period)
19+
20+
beats = Heartbeat.where(user_id: ids, time: range)
21+
.coding_only
22+
.with_valid_timestamps
23+
.joins(:user)
24+
.where.not(users: { github_uid: nil })
25+
26+
totals = beats.group(:user_id).duration_seconds
27+
totals = totals.filter { |_, seconds| seconds > 60 }
28+
29+
streak_ids = totals.keys
30+
streaks = streak_ids.any? ? Heartbeat.daily_streaks_for_users(streak_ids, start_date: 30.days.ago) : {}
31+
32+
entries = totals.map do |user_id, seconds|
33+
entry = LeaderboardEntry.new(
34+
leaderboard: board,
35+
user_id: user_id,
36+
total_seconds: seconds,
37+
streak_count: streaks[user_id] || 0
38+
)
39+
40+
entry.user = users_map[user_id]
41+
entry
42+
end.sort_by(&:total_seconds).reverse
43+
44+
board.define_singleton_method(:entries) { entries }
45+
board.define_singleton_method(:scope_name) { scope }
46+
47+
board
48+
end
49+
end

app/services/leaderboard_cache.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module LeaderboardCache
2+
CACHE_EXPIRATION = 10.minutes
3+
4+
module_function
5+
6+
def global_key(period, date)
7+
"leaderboard_#{period}_#{date}"
8+
end
9+
10+
def timezone_key(offset, date, period)
11+
"tz_leaderboard_#{offset}_#{date}_#{period}"
12+
end
13+
14+
def write(key, data)
15+
Rails.cache.write(key, data, expires_in: CACHE_EXPIRATION)
16+
end
17+
18+
def read(key)
19+
Rails.cache.read(key)
20+
end
21+
22+
def fetch(key, &block)
23+
Rails.cache.fetch(key, expires_in: CACHE_EXPIRATION, &block)
24+
end
25+
end

0 commit comments

Comments
 (0)