make leaderboards go vrooom (#1120)

* make leaderboards go vrooom

* goog
This commit is contained in:
Mahad Kalam 2026-03-30 13:58:18 +01:00 committed by GitHub
parent 91a0daf23f
commit 8ce245d8c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 151 additions and 71 deletions

3
.gitignore vendored
View file

@ -56,3 +56,6 @@ pg-dump*
# Generated by `rails docs:generate_llms`
/public/llms.txt
/public/llms-full.txt
rust-stats-server/target/
scratch/

View file

@ -29,20 +29,18 @@ class LeaderboardUpdateJob < ApplicationJob
Rails.logger.info "Building leaderboard for #{period} on #{date}"
range = LeaderboardDateRange.calculate(date, period)
timestamp = Time.current
eligible_users = User.where.not(github_uid: nil)
.where.not(trust_level: User.trust_levels[:red])
ActiveRecord::Base.transaction do
# 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] })
heartbeat_query = Heartbeat.where(user_id: eligible_users.select(:id), time: range)
.leaderboard_eligible
data = heartbeat_query.group(:user_id).duration_seconds
.filter { |_, seconds| seconds > 60 }
streaks = Heartbeat.daily_streaks_for_users(data.keys)
streaks = Heartbeat.daily_streaks_for_users(data.keys, exclude_browser_time: true)
entries = data.map do |user_id, seconds|
{
@ -50,8 +48,8 @@ class LeaderboardUpdateJob < ApplicationJob
user_id: user_id,
total_seconds: seconds,
streak_count: streaks[user_id] || 0,
created_at: Time.current,
updated_at: Time.current
created_at: timestamp,
updated_at: timestamp
}
end
@ -63,7 +61,7 @@ class LeaderboardUpdateJob < ApplicationJob
board.entries.delete_all
end
board.update!(finished_generating_at: Time.current)
board.update!(finished_generating_at: timestamp)
end
# Cache the board

View file

@ -1,9 +1,31 @@
module Heartbeatable
extend ActiveSupport::Concern
BROWSER_EDITORS = %w[
arc
brave
chrome
chromium
edge
firefox
floorp
librewolf
microsoft-edge
opera
opera-gx
safari
vivaldi
waterfox
zen
].freeze
included do
# Filter heartbeats to only include those with category equal to "coding"
scope :coding_only, -> { where(category: "coding") }
scope :excluding_browser_time, -> {
where("editor IS NULL OR LOWER(editor) NOT IN (?)", BROWSER_EDITORS)
}
scope :leaderboard_eligible, -> { coding_only.excluding_browser_time.with_valid_timestamps }
# This is to prevent PG timestamp overflow errors if someones gives us a
# heartbeat with a time that is enormously far in the future.
@ -97,19 +119,27 @@ module Heartbeatable
end
end
def daily_streaks_for_users(user_ids, start_date: 31.days.ago)
def daily_streaks_for_users(user_ids, start_date: 31.days.ago, exclude_browser_time: false)
return {} if user_ids.empty?
start_date = [ start_date, 30.days.ago ].max
keys = user_ids.map { |id| "user_streak_#{id}" }
cache_prefix = exclude_browser_time ? "user_streak_without_browser" : "user_streak"
keys = user_ids.map { |id| "#{cache_prefix}_#{id}" }
streak_cache = Rails.cache.read_multi(*keys)
uncached_users = user_ids.select { |id| streak_cache["user_streak_#{id}"].nil? }
uncached_users = user_ids.select { |id| streak_cache["#{cache_prefix}_#{id}"].nil? }
if uncached_users.empty?
return user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
return user_ids.index_with { |id| streak_cache["#{cache_prefix}_#{id}"] || 0 }
end
timeout = heartbeat_timeout_duration.to_i
day_group_sql = "DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE users.timezone)"
streak_diff_sql = <<~SQL.squish
LEAST(
time - LAG(time) OVER (PARTITION BY user_id, #{day_group_sql} ORDER BY time),
#{timeout}
) as diff
SQL
raw_durations = joins(:user)
.where(user_id: uncached_users)
.coding_only
@ -118,9 +148,10 @@ module Heartbeatable
.select(
:user_id,
"users.timezone as user_timezone",
Arel.sql("DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE users.timezone) as day_group"),
Arel.sql("LEAST(time - LAG(time) OVER (PARTITION BY user_id, DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE users.timezone) ORDER BY time), #{timeout}) as diff")
Arel.sql("#{day_group_sql} as day_group"),
Arel.sql(streak_diff_sql)
)
raw_durations = raw_durations.excluding_browser_time if exclude_browser_time
# Then aggregate the results
daily_durations = connection.select_all(
@ -152,7 +183,7 @@ module Heartbeatable
}
end
result = user_ids.index_with { |id| streak_cache["user_streak_#{id}"] || 0 }
result = user_ids.index_with { |id| streak_cache["#{cache_prefix}_#{id}"] || 0 }
# Then calculate streaks for each user
daily_durations.each do |user_id, data|
@ -183,7 +214,7 @@ module Heartbeatable
result[user_id] = streak
# Cache the streak for 1 hour
Rails.cache.write("user_streak_#{user_id}", streak, expires_in: 1.hour)
Rails.cache.write("#{cache_prefix}_#{user_id}", streak, expires_in: 1.hour)
end
result

View file

@ -1,49 +0,0 @@
module LeaderboardBuilder
module_function
def build_for_users(users, date, scope, period)
date = Date.current if date.blank?
board = ::Leaderboard.new(
start_date: date,
period_type: period,
finished_generating_at: Time.current
)
ids = users.pluck(:id)
return board if ids.empty?
users_map = users.index_by(&:id)
range = LeaderboardDateRange.calculate(date, period)
beats = Heartbeat.where(user_id: ids, time: range)
.coding_only
.with_valid_timestamps
.joins(:user)
.where.not(users: { github_uid: nil })
totals = beats.group(:user_id).duration_seconds
totals = totals.filter { |_, seconds| seconds > 60 }
streak_ids = totals.keys
streaks = streak_ids.any? ? Heartbeat.daily_streaks_for_users(streak_ids, start_date: 30.days.ago) : {}
entries = totals.map do |user_id, seconds|
entry = LeaderboardEntry.new(
leaderboard: board,
user_id: user_id,
total_seconds: seconds,
streak_count: streaks[user_id] || 0
)
entry.user = users_map[user_id]
entry
end.sort_by(&:total_seconds).reverse
board.define_singleton_method(:entries) { entries }
board.define_singleton_method(:scope_name) { scope }
board
end
end

View file

@ -1,6 +1,5 @@
CI.run do
step "Setup: Rails", "bin/setup --skip-server"
step "Setup: Frontend", "bun install --frozen-lockfile"
step "Style: Ruby", "bin/rubocop"
step "Zeitwerk", "bin/rails zeitwerk:check"
@ -19,7 +18,7 @@ CI.run do
step "Frontend: Lint", "bun run format:svelte:check"
if success?
step "Signoff: All systems go. Ready for merge and deploy."
heading "Signoff: All systems go. Ready for merge and deploy.", type: :success
else
failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
end

View file

@ -0,0 +1,66 @@
require "test_helper"
class LeaderboardUpdateJobTest < ActiveJob::TestCase
setup do
Rails.cache.clear
end
teardown do
Rails.cache.clear
end
test "perform excludes browser editor heartbeats from persisted leaderboard entries" do
coded_user = create_user(username: "lb_job_coded", github_uid: "GH_LEADERBOARD_JOB_CODED")
browser_only_user = create_user(username: "lb_job_browser", github_uid: "GH_LEADERBOARD_JOB_BROWSER")
create_heartbeat_pair(user: coded_user, started_at: today_at(9, 0), editor: "vscode")
create_heartbeat_pair(user: coded_user, started_at: today_at(11, 0), editor: "firefox")
create_heartbeat_pair(user: browser_only_user, started_at: today_at(13, 0), editor: "firefox")
LeaderboardUpdateJob.perform_now(:daily, Date.current, force_update: true)
board = Leaderboard.find_by!(
start_date: Date.current,
period_type: :daily,
timezone_utc_offset: nil
)
assert_equal [ coded_user.id ], board.entries.order(:user_id).pluck(:user_id)
assert_equal 120, board.entries.find_by!(user_id: coded_user.id).total_seconds
end
private
def create_user(username:, github_uid:)
User.create!(
username: username,
github_uid: github_uid,
timezone: "UTC"
)
end
def create_heartbeat_pair(user:, started_at:, editor:)
user.heartbeats.create!(
entity: "src/#{editor}.rb",
type: "file",
category: "coding",
editor: editor,
time: started_at.to_f,
project: "leaderboard-job-test",
source_type: :test_entry
)
user.heartbeats.create!(
entity: "src/#{editor}.rb",
type: "file",
category: "coding",
editor: editor,
time: (started_at + 2.minutes).to_f,
project: "leaderboard-job-test",
source_type: :test_entry
)
end
def today_at(hour, minute)
Time.current.change(hour: hour, min: minute, sec: 0)
end
end

View file

@ -1,8 +1,16 @@
require "test_helper"
class HeartbeatTest < ActiveSupport::TestCase
setup do
Rails.cache.clear
end
teardown do
Rails.cache.clear
end
test "soft delete hides record from default scope and restore brings it back" do
user = User.create!
user = User.create!(timezone: "UTC")
heartbeat = user.heartbeats.create!(
entity: "src/main.rb",
type: "file",
@ -23,4 +31,28 @@ class HeartbeatTest < ActiveSupport::TestCase
assert_includes Heartbeat.all, heartbeat
end
test "daily streak cache is separated for browser-filtered leaderboard streaks" do
user = User.create!(timezone: "UTC", username: "hb_streak_cache")
create_heartbeat_sequence(user: user, started_at: Time.current.beginning_of_day + 9.hours, editor: "firefox")
assert_equal 1, Heartbeat.daily_streaks_for_users([ user.id ])[user.id]
assert_equal 0, Heartbeat.daily_streaks_for_users([ user.id ], exclude_browser_time: true)[user.id]
end
private
def create_heartbeat_sequence(user:, started_at:, editor:, count: 9)
count.times do |offset|
user.heartbeats.create!(
entity: "src/#{editor}.rb",
type: "file",
category: "coding",
editor: editor,
time: (started_at + (offset * 2).minutes).to_f,
project: "heartbeat-test",
source_type: :test_entry
)
end
end
end