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` # Generated by `rails docs:generate_llms`
/public/llms.txt /public/llms.txt
/public/llms-full.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}" Rails.logger.info "Building leaderboard for #{period} on #{date}"
range = LeaderboardDateRange.calculate(date, period) 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 ActiveRecord::Base.transaction do
# Build the base heartbeat query heartbeat_query = Heartbeat.where(user_id: eligible_users.select(:id), time: range)
heartbeat_query = Heartbeat.where(time: range) .leaderboard_eligible
.with_valid_timestamps
.joins(:user)
.coding_only
.where.not(users: { github_uid: nil })
.where.not(users: { trust_level: User.trust_levels[:red] })
data = heartbeat_query.group(:user_id).duration_seconds data = heartbeat_query.group(:user_id).duration_seconds
.filter { |_, seconds| seconds > 60 } .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| entries = data.map do |user_id, seconds|
{ {
@ -50,8 +48,8 @@ class LeaderboardUpdateJob < ApplicationJob
user_id: user_id, user_id: user_id,
total_seconds: seconds, total_seconds: seconds,
streak_count: streaks[user_id] || 0, streak_count: streaks[user_id] || 0,
created_at: Time.current, created_at: timestamp,
updated_at: Time.current updated_at: timestamp
} }
end end
@ -63,7 +61,7 @@ class LeaderboardUpdateJob < ApplicationJob
board.entries.delete_all board.entries.delete_all
end end
board.update!(finished_generating_at: Time.current) board.update!(finished_generating_at: timestamp)
end end
# Cache the board # Cache the board

View file

@ -1,9 +1,31 @@
module Heartbeatable module Heartbeatable
extend ActiveSupport::Concern 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 included do
# Filter heartbeats to only include those with category equal to "coding" # Filter heartbeats to only include those with category equal to "coding"
scope :coding_only, -> { where(category: "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 # This is to prevent PG timestamp overflow errors if someones gives us a
# heartbeat with a time that is enormously far in the future. # heartbeat with a time that is enormously far in the future.
@ -97,19 +119,27 @@ module Heartbeatable
end end
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? return {} if user_ids.empty?
start_date = [ start_date, 30.days.ago ].max 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) 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? 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 end
timeout = heartbeat_timeout_duration.to_i 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) raw_durations = joins(:user)
.where(user_id: uncached_users) .where(user_id: uncached_users)
.coding_only .coding_only
@ -118,9 +148,10 @@ module Heartbeatable
.select( .select(
:user_id, :user_id,
"users.timezone as user_timezone", "users.timezone as user_timezone",
Arel.sql("DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE users.timezone) as day_group"), Arel.sql("#{day_group_sql} 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(streak_diff_sql)
) )
raw_durations = raw_durations.excluding_browser_time if exclude_browser_time
# Then aggregate the results # Then aggregate the results
daily_durations = connection.select_all( daily_durations = connection.select_all(
@ -152,7 +183,7 @@ module Heartbeatable
} }
end 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 # Then calculate streaks for each user
daily_durations.each do |user_id, data| daily_durations.each do |user_id, data|
@ -183,7 +214,7 @@ module Heartbeatable
result[user_id] = streak result[user_id] = streak
# Cache the streak for 1 hour # 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 end
result 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 CI.run do
step "Setup: Rails", "bin/setup --skip-server" step "Setup: Rails", "bin/setup --skip-server"
step "Setup: Frontend", "bun install --frozen-lockfile"
step "Style: Ruby", "bin/rubocop" step "Style: Ruby", "bin/rubocop"
step "Zeitwerk", "bin/rails zeitwerk:check" step "Zeitwerk", "bin/rails zeitwerk:check"
@ -19,7 +18,7 @@ CI.run do
step "Frontend: Lint", "bun run format:svelte:check" step "Frontend: Lint", "bun run format:svelte:check"
if success? 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 else
failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
end 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" require "test_helper"
class HeartbeatTest < ActiveSupport::TestCase 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 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!( heartbeat = user.heartbeats.create!(
entity: "src/main.rb", entity: "src/main.rb",
type: "file", type: "file",
@ -23,4 +31,28 @@ class HeartbeatTest < ActiveSupport::TestCase
assert_includes Heartbeat.all, heartbeat assert_includes Heartbeat.all, heartbeat
end 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 end