optimize mini leaderboards

This commit is contained in:
Echo
2025-06-25 20:41:38 -04:00
parent 2212349454
commit 05da3a6b84
7 changed files with 103 additions and 39 deletions

View File

@@ -0,0 +1,20 @@
class WarmMiniLeaderboardCacheJob < ApplicationJob
queue_as :default
def perform
offsets = [ -8, -7, -6, -5, -4, -3, 0, 1, 2, 8, 9, 10, 11, 12 ]
offsets.each do |offset|
begin
LeaderboardGenerator.generate_timezone_offset_leaderboard(
Date.current,
offset,
:daily
)
Rails.logger.info "Warmed mini leaderboard cache for UTC#{offset >= 0 ? '+' : ''}#{offset}"
rescue => e
Rails.logger.error "Failed to warm cache for UTC#{offset >= 0 ? '+' : ''}#{offset}: #{e.message}"
end
end
end
end

View File

@@ -98,9 +98,19 @@ module Heartbeatable
end
def daily_streaks_for_users(user_ids, start_date: 31.days.ago)
# First get the raw durations using window function
return {} if user_ids.empty?
start_date = [ start_date, 30.days.ago ].max
keys = user_ids.map { |id| "user_streak_#{id}" }
streak_cache = Rails.cache.read_multi(*keys)
uncached_users = user_ids.select { |id| streak_cache["user_streak_#{id}"].nil? }
if uncached_users.empty?
return user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
end
raw_durations = joins(:user)
.where(user_id: user_ids)
.where(user_id: uncached_users)
.coding_only
.with_valid_timestamps
.where(time: start_date..Time.current)
@@ -128,8 +138,7 @@ module Heartbeatable
}
end
# Initialize the result hash with zeros for all users
result = user_ids.index_with { 0 }
result = user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
# Then calculate streaks for each user
daily_durations.each do |user_id, data|
@@ -158,6 +167,9 @@ module Heartbeatable
end
result[user_id] = streak
# Cache the streak for 1 hour
Rails.cache.write("user_streak_#{user_id}", streak, expires_in: 1.hour)
end
result

View File

@@ -11,14 +11,24 @@ class LeaderboardGenerator
def generate_timezone_offset_leaderboard(date, utc_offset, period_type = :daily)
date = Date.current if date.blank?
users = User.users_in_timezone_offset(utc_offset).not_convicted
generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type)
cache_key = "timezone_leaderboard_#{utc_offset}_#{date}_#{period_type}"
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
users = User.users_in_timezone_offset(utc_offset).not_convicted
generate_leaderboard_for_users(users, date, "UTC#{utc_offset >= 0 ? '+' : ''}#{utc_offset}", period_type)
end
end
def generate_timezone_leaderboard(date, timezone, period_type = :daily)
date = Date.current if date.blank?
users = User.users_in_timezone(timezone).not_convicted
generate_leaderboard_for_users(users, date, timezone, period_type)
cache_key = "timezone_leaderboard_#{timezone.gsub('/', '_')}_#{date}_#{period_type}"
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
users = User.users_in_timezone(timezone).not_convicted
generate_leaderboard_for_users(users, date, timezone, period_type)
end
end
private
@@ -34,10 +44,13 @@ class LeaderboardGenerator
finished_generating_at: Time.current
)
# Get user IDs
# Get user IDs and preload users hash for faster lookups
user_ids = users.pluck(:id)
return leaderboard if user_ids.empty?
# Preload users into a hash for O(1) lookups
users_hash = users.index_by(&:id)
# Calculate heartbeats for the date range in UTC
date_range = case period_type
when :weekly
@@ -48,7 +61,7 @@ class LeaderboardGenerator
date.all_day
end
# Get heartbeats for these users
# Get heartbeats for these users - limit to reduce query size
heartbeats = Heartbeat.where(user_id: user_ids, time: date_range)
.coding_only
.with_valid_timestamps
@@ -59,8 +72,10 @@ class LeaderboardGenerator
user_totals = heartbeats.group(:user_id).duration_seconds
user_totals = user_totals.filter { |_, total_seconds| total_seconds > 60 }
# Get streaks for all users at once
streaks = Heartbeat.daily_streaks_for_users(user_totals.keys) if user_totals.any?
# Only calculate streaks for users who actually have time today
# This significantly reduces the streak calculation overhead
streak_user_ids = user_totals.keys
streaks = streak_user_ids.any? ? Heartbeat.daily_streaks_for_users(streak_user_ids, start_date: 30.days.ago) : {}
# Create virtual leaderboard entries
entries = user_totals.map do |user_id, total_seconds|
@@ -71,8 +86,8 @@ class LeaderboardGenerator
streak_count: streaks[user_id] || 0
)
# Manually set the user association to avoid N+1 queries
entry.user = users.find { |u| u.id == user_id }
# Use preloaded users hash instead of find
entry.user = users_hash[user_id]
entry
end.sort_by(&:total_seconds).reverse

View File

@@ -2,23 +2,17 @@
<% if leaderboard.present? %>
<%
entries = leaderboard.entries.respond_to?(:order) ? leaderboard.entries.order(total_seconds: :desc) : leaderboard.entries
if current_user
user_rank = entries.find_index { |entry| entry.user_id == current_user.id }
if user_rank && user_rank >= 3
# Show top 2 entries and immediate competition
top_entries = entries[0..1]
competition_entries = entries[[user_rank - 1, 2].max..[user_rank + 1, entries.size - 1].min]
mini_leaderboard_entries = top_entries + competition_entries
show_top_entries = false
else
# Show top 3 entries (either user is in top 3 or not on leaderboard)
mini_leaderboard_entries = entries.first(3)
show_top_entries = true
end
top_entries = entries.first(3)
user_entry = current_user ? entries.find { |entry| entry.user_id == current_user.id } : nil
user_rank = user_entry ? entries.index(user_entry) : nil
if user_entry && user_rank && user_rank >= 3
mini_leaderboard_entries = top_entries + [user_entry]
show_user_separately = true
else
# Not logged in, show top 3
mini_leaderboard_entries = entries.first(3)
show_top_entries = true
mini_leaderboard_entries = top_entries
show_user_separately = false
end
%>
@@ -36,20 +30,22 @@
</p>
<div class="space-y-2">
<% mini_leaderboard_entries.each_with_index do |entry, idx| %>
<% is_competition = !show_top_entries && idx >= 2 %>
<%
actual_rank = entries.index(entry)
is_user_entry = show_user_separately && idx == 3
%>
<div class="flex items-center p-3 rounded-lg hover:bg-dark transition-colors duration-200 <%= 'bg-dark border border-red' if entry.user_id == current_user&.id %>">
<% if !is_competition %>
<% rank_emoji = case entries.index(entry)
<% if is_user_entry && actual_rank > 2 %>
<div class="w-full text-center text-muted font-bold tracking-widest text-sm py-2">...</div>
<div class="w-8 text-center text-lg font-bold text-muted"><%= actual_rank + 1 %></div>
<% else %>
<% rank_emoji = case actual_rank
when 0 then "🥇"
when 1 then "🥈"
when 2 then "🥉"
else actual_rank + 1
end %>
<div class="w-8 text-center text-lg"><%= rank_emoji %></div>
<% else %>
<% if idx == 2 && entries.index(entry) - entries.index(mini_leaderboard_entries[1]) > 1 %>
<div class="text-center text-muted font-bold tracking-widest text-lg py-1">...</div>
<% end %>
<div class="w-8 text-center text-lg font-bold text-muted"><%= entries.index(entry) + 1 %></div>
<% end %>
<div class="flex-1 mx-3 min-w-0">
<div class="flex items-center gap-2 flex-wrap">

View File

@@ -0,0 +1,11 @@
# Schedule mini leaderboard cache warming
# This will run every 5 minutes to keep the cache warm
Rails.application.config.after_initialize do
if defined?(Sidekiq::Cron::Job)
Sidekiq::Cron::Job.create(
name: "Warm Mini Leaderboard Cache",
cron: "*/5 * * * *",
class: "WarmMiniLeaderboardCacheJob"
)
end
end

View File

@@ -0,0 +1,7 @@
class AddIndexesForLeaderboardPerformance < ActiveRecord::Migration[8.0]
def change
add_index :heartbeats, [ :user_id, :time, :category ], name: 'index_heartbeats_on_user_time_category'
add_index :users, [ :timezone, :trust_level ], name: 'index_users_on_timezone_trust_level'
add_index :users, :github_uid, name: 'index_users_on_github_uid'
end
end

5
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_23_135342) do
ActiveRecord::Schema[8.0].define(version: 2025_06_26_001500) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database
@@ -248,6 +248,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_23_135342) do
t.index ["fields_hash"], name: "index_heartbeats_on_fields_hash_when_not_deleted", unique: true, where: "(deleted_at IS NULL)"
t.index ["raw_heartbeat_upload_id"], name: "index_heartbeats_on_raw_heartbeat_upload_id"
t.index ["source_type", "time", "user_id", "project"], name: "index_heartbeats_on_source_type_time_user_project"
t.index ["user_id", "time", "category"], name: "index_heartbeats_on_user_time_category"
t.index ["user_id", "time"], name: "idx_heartbeats_user_time_active", where: "(deleted_at IS NULL)"
t.index ["user_id"], name: "index_heartbeats_on_user_id"
end
@@ -495,7 +496,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_23_135342) do
t.boolean "allow_public_stats_lookup", default: true, null: false
t.boolean "default_timezone_leaderboard", default: true, null: false
t.index ["github_uid", "github_access_token"], name: "index_users_on_github_uid_and_access_token"
t.index ["github_uid"], name: "index_users_on_github_uid"
t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true
t.index ["timezone", "trust_level"], name: "index_users_on_timezone_trust_level"
t.index ["timezone"], name: "index_users_on_timezone"
end