Regional leaderboards persist to database (#494)

This commit is contained in:
Max Wofford
2025-08-25 08:23:51 -04:00
committed by GitHub
parent 3a803260bb
commit 048ce1a12f
6 changed files with 83 additions and 196 deletions

View File

@@ -10,51 +10,78 @@ class LeaderboardUpdateJob < ApplicationJob
drop: true
)
def perform(period = :daily, date = Date.current)
def perform(period = :daily, date = Date.current, force_update: false)
date = LeaderboardDateRange.normalize_date(date, period)
Rails.logger.info "Starting leaderboard generation for #{period} on #{date}"
# global
build_leaderboard(date, period, nil, nil, force_update)
board = build_global(date, period)
build_timezones(date, period)
Rails.logger.info "Completed leaderboard generation for #{period} on #{date}"
board
rescue => e
Rails.logger.error "Failed to update leaderboard: #{e.message}"
Honeybadger.notify(e, context: { period: period, date: date })
raise
# Build timezone leaderboards
range = LeaderboardDateRange.calculate(date, period)
timezones_for_users_in(range).each do |timezone|
offset = User.timezone_to_utc_offset(timezone)
build_leaderboard(date, period, offset, timezone, force_update)
end
end
private
def build_global(date, period)
range = LeaderboardDateRange.calculate(date, period)
def timezones_for_users_in(range)
# Expand range by 1 day in both directions to catch users in all timezones
expanded_range = (range.begin - 1.day)...(range.end + 1.day)
User.joins(:heartbeats)
.where(heartbeats: { time: expanded_range })
.where.not(timezone: nil)
.distinct
.pluck(:timezone)
.compact
end
def build_leaderboard(date, period, timezone_offset = nil, timezone = nil, force_update = false)
board = ::Leaderboard.find_or_create_by!(
start_date: date,
period_type: period,
timezone_utc_offset: nil
) do |lb|
lb.finished_generating_at = nil
timezone_utc_offset: timezone_offset
)
return board if board.finished_generating_at.present? && !force_update
if timezone_offset
Rails.logger.info "Building timezone leaderboard for #{timezone} (UTC#{timezone_offset >= 0 ? '+' : ''}#{timezone_offset})"
else
Rails.logger.info "Building global leaderboard"
end
return board if board.finished_generating_at.present?
# Calculate timezone-aware range
range = if timezone
Time.use_zone(timezone) { LeaderboardDateRange.calculate(date, period) }
else
LeaderboardDateRange.calculate(date, period)
end
ActiveRecord::Base.transaction do
board.entries.delete_all
data = Heartbeat.where(time: range)
.with_valid_timestamps
.joins(:user)
.coding_only
.where.not(users: { github_uid: nil })
.group(:user_id)
.duration_seconds
data = data.filter { |_, seconds| seconds > 60 }
# Build the base heartbeat query
heartbeat_query = Heartbeat.where(time: range)
.with_valid_timestamps
.joins(:user)
.coding_only
.where.not(users: { github_uid: nil })
.where.not(users: { trust_level: User.trust_levels[:red] })
convicted = User.where(trust_level: User.trust_levels[:red]).pluck(:id)
data = data.reject { |user_id, _| convicted.include?(user_id) }
# Filter by timezone if specified
if timezone_offset
users_in_tz = User.users_in_timezone_offset(timezone_offset).not_convicted
user_ids = users_in_tz.pluck(:id)
return board if user_ids.empty?
heartbeat_query = heartbeat_query.where(user_id: user_ids)
end
data = heartbeat_query.group(:user_id).duration_seconds
.filter { |_, seconds| seconds > 60 }
streaks = Heartbeat.daily_streaks_for_users(data.keys)
@@ -73,41 +100,15 @@ class LeaderboardUpdateJob < ApplicationJob
board.update!(finished_generating_at: Time.current)
end
key = LeaderboardCache.global_key(period, date)
LeaderboardCache.write(key, board)
# Cache the board
cache_key = timezone_offset ?
LeaderboardCache.timezone_key(timezone_offset, date, period) :
LeaderboardCache.global_key(period, date)
LeaderboardCache.write(cache_key, board)
Rails.logger.debug "Persisted #{timezone_offset ? 'timezone' : 'global'} leaderboard with #{board.entries.count} entries"
board
end
def build_timezones(date, period)
range = LeaderboardDateRange.calculate(date, period)
user_timezones = User.joins(:heartbeats)
.where(heartbeats: { time: range })
.where.not(timezone: nil)
.distinct
.pluck(:timezone)
.compact
offsets = user_timezones.map { |tz| User.timezone_to_utc_offset(tz) }.compact.uniq
Rails.logger.info "Generating timezone leaderboards for #{offsets.size} active UTC offsets"
offsets.each do |offset|
build_timezone(date, period, offset)
end
end
def build_timezone(date, period, offset)
key = LeaderboardCache.timezone_key(offset, date, period)
data = LeaderboardCache.fetch(key) do
users = User.users_in_timezone_offset(offset).not_convicted
LeaderboardBuilder.build_for_users(users, date, "UTC#{offset >= 0 ? '+' : ''}#{offset}", period)
end
Rails.logger.debug "Cached timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} with #{data&.entries&.size || 0} entries"
data
end
end

View File

@@ -1,41 +0,0 @@
class TimezoneLeaderboardJob < ApplicationJob
queue_as :latency_5m
include GoodJob::ActiveJobExtensions::Concurrency
# Limits concurrency to 1 job per timezone/period/date combination
good_job_control_concurrency_with(
key: -> { "timezone_#{arguments[0]}_#{arguments[1]}_#{arguments[2]}" },
total: 1,
drop: true
)
def perform(period = :daily, date = Date.current, offset = 0)
date = LeaderboardDateRange.normalize_date(date, period)
Rails.logger.info "Generating timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} (#{period}, #{date})"
key = LeaderboardCache.timezone_key(offset, date, period)
# Generate the leaderboard
board = build_timezone(date, period, offset)
# Cache it for 10 minutes
LeaderboardCache.write(key, board)
Rails.logger.info "Cached timezone leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset} with #{board&.entries&.size || 0} entries"
board
rescue => e
Rails.logger.error "Failed to generate timezone leaderboard for UTC#{offset}: #{e.message}"
Honeybadger.notify(e, context: { period: period, date: date, offset: offset })
raise
end
private
def build_timezone(date, period, offset)
users = User.users_in_timezone_offset(offset).not_convicted
LeaderboardBuilder.build_for_users(users, date, "UTC#{offset >= 0 ? '+' : ''}#{offset}", period)
end
end

View File

@@ -1,80 +0,0 @@
class TimezoneLeaderboardUpdateJob < ApplicationJob
queue_as :latency_10s
include GoodJob::ActiveJobExtensions::Concurrency
# Limits concurrency to 1 job per date
good_job_control_concurrency_with(
key: -> { "timezone_daily_#{arguments[0] || Date.current.to_s}" },
total: 1,
drop: true
)
def perform(date = Date.current)
parsed_date = date.is_a?(Date) ? date : Date.parse(date.to_s)
leaderboard = Leaderboard.create!(
start_date: parsed_date,
period_type: :daily_timezone_normalized
)
Rails.logger.info "Starting timezone-normalized leaderboard generation for #{parsed_date}"
ActiveRecord::Base.transaction do
# Get all unique timezones
timezones = User.where.not(timezone: nil).distinct.pluck(:timezone)
entries_data = []
timezones.each do |timezone|
# Calculate the date range for this timezone
timezone_date_range = Time.use_zone(timezone) do
parsed_date.in_time_zone(timezone).all_day
end
# Get all heartbeats for users in this timezone during their local day
timezone_heartbeats = Heartbeat.joins(:user)
.where(users: { timezone: timezone })
.where(time: timezone_date_range)
.coding_only
.with_valid_timestamps
.where.not(users: { github_uid: nil })
# Group by user and calculate totals
user_totals = timezone_heartbeats.group(:user_id).duration_seconds
user_totals = user_totals.filter { |_, total_seconds| total_seconds > 60 }
# Get streaks for all users at once
user_ids = user_totals.keys
streaks = Heartbeat.daily_streaks_for_users(user_ids) if user_ids.any?
# Build entries data
user_totals.each do |user_id, total_seconds|
entries_data << {
leaderboard_id: leaderboard.id,
user_id: user_id,
total_seconds: total_seconds,
streak_count: streaks[user_id] || 0
}
end
end
LeaderboardEntry.insert_all!(entries_data) if entries_data.any?
end
leaderboard.finished_generating_at = Time.current
leaderboard.save!
# Clean up old timezone-normalized leaderboards for this date
Leaderboard.where.not(id: leaderboard.id)
.where(start_date: parsed_date, period_type: :daily_timezone_normalized)
.where(deleted_at: nil)
.update_all(deleted_at: Time.current)
leaderboard
rescue => e
Rails.logger.error "Failed to update timezone-normalized leaderboard: #{e.message}"
raise
rescue Date::Error
raise ArgumentError, "Invalid date format provided"
end
end

View File

@@ -18,18 +18,21 @@ class LeaderboardService
private
def get_timezone(date, period, offset)
date = LeaderboardDateRange.normalize_date(date, period)
key = LeaderboardCache.timezone_key(offset, date, period)
board = LeaderboardCache.read(key)
return board if board.present?
board = ::Leaderboard.where.not(finished_generating_at: nil)
.find_by(start_date: date, period_type: period, timezone_utc_offset: offset, deleted_at: nil)
if board.present?
Rails.logger.debug "Cache HIT for timezone leaderboard UTC#{offset >= 0 ? '+' : ''}#{offset}"
LeaderboardCache.write(key, board)
return board
end
Rails.logger.debug "Cache MISS for timezone leaderboard UTC#{offset >= 0 ? '+' : ''}#{offset}"
TimezoneLeaderboardJob.perform_later(period, date, offset)
Rails.logger.info "Falling back to global leaderboard for UTC#{offset >= 0 ? '+' : ''}#{offset}"
::LeaderboardUpdateJob.perform_later(period, date)
get_global(date, period)
end
@@ -37,7 +40,9 @@ class LeaderboardService
date = LeaderboardDateRange.normalize_date(date, period)
key = LeaderboardCache.global_key(period, date)
board = LeaderboardCache.read(key)
return board if board.present?
board = ::Leaderboard.where.not(finished_generating_at: nil)
.find_by(start_date: date, period_type: period, timezone_utc_offset: nil, deleted_at: nil)
@@ -46,8 +51,7 @@ class LeaderboardService
return board
end
Rails.logger.info "No leaderboard found for #{period} #{date}, triggering background generation"
LeaderboardUpdateJob.perform_later(period, date)
::LeaderboardUpdateJob.perform_later(period, date)
nil
end
end

View File

@@ -23,11 +23,11 @@
<div class="inline-flex rounded-full p-1 mb-4 ml-4">
<%= link_to "Timezone", leaderboards_path(period_type: @period_type, scope: 'timezone'),
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'timezone' ? ' text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'timezone' ? ' bg-primary text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
<%= link_to "Regional", leaderboards_path(period_type: @period_type, scope: 'regional'),
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'regional' ? ' text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'regional' ? ' bg-primary text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
<%= link_to "Global", leaderboards_path(period_type: @period_type, scope: 'global'),
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'global' ? ' text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@scope == 'global' ? ' bg-primary text-white' : 'text-muted hover:text-white'}", style: "background:none; border:none;" %>
</div>
<% if current_user && current_user.github_uid.blank? %>

View File

@@ -30,17 +30,20 @@ Rails.application.configure do
daily_leaderboard_update: {
cron: "* * * * *",
class: "LeaderboardUpdateJob",
args: [ :daily ]
args: [ :daily ],
kwargs: { force_update: true }
},
weekly_leaderboard_update: {
cron: "*/2 * * * *",
class: "LeaderboardUpdateJob",
args: [ :weekly ]
args: [ :weekly ],
kwargs: { force_update: true }
},
last_7_days_leaderboard_update: {
cron: "*/7 * * * *",
class: "LeaderboardUpdateJob",
args: [ :last_7_days ]
args: [ :last_7_days ],
kwargs: { force_update: true }
},
# sailors_log_poll: {
# cron: "*/2 * * * *",