From 8ce245d8c41c3e68eabdccf780417ceecc2bbfdf Mon Sep 17 00:00:00 2001 From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:58:18 +0100 Subject: [PATCH] make leaderboards go vrooom (#1120) * make leaderboards go vrooom * goog --- .gitignore | 3 ++ app/jobs/leaderboard_update_job.rb | 20 ++++--- app/models/concerns/heartbeatable.rb | 47 ++++++++++++++--- app/services/leaderboard_builder.rb | 49 ------------------ config/ci.rb | 3 +- test/jobs/leaderboard_update_job_test.rb | 66 ++++++++++++++++++++++++ test/models/heartbeat_test.rb | 34 +++++++++++- 7 files changed, 151 insertions(+), 71 deletions(-) delete mode 100644 app/services/leaderboard_builder.rb create mode 100644 test/jobs/leaderboard_update_job_test.rb diff --git a/.gitignore b/.gitignore index 0b0e4f1..e0bfbd0 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ pg-dump* # Generated by `rails docs:generate_llms` /public/llms.txt /public/llms-full.txt + +rust-stats-server/target/ +scratch/ diff --git a/app/jobs/leaderboard_update_job.rb b/app/jobs/leaderboard_update_job.rb index 39b9486..be34c6f 100644 --- a/app/jobs/leaderboard_update_job.rb +++ b/app/jobs/leaderboard_update_job.rb @@ -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 diff --git a/app/models/concerns/heartbeatable.rb b/app/models/concerns/heartbeatable.rb index b32f24a..c171667 100644 --- a/app/models/concerns/heartbeatable.rb +++ b/app/models/concerns/heartbeatable.rb @@ -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 diff --git a/app/services/leaderboard_builder.rb b/app/services/leaderboard_builder.rb deleted file mode 100644 index 43001b6..0000000 --- a/app/services/leaderboard_builder.rb +++ /dev/null @@ -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 diff --git a/config/ci.rb b/config/ci.rb index 71053d7..38bb616 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -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 diff --git a/test/jobs/leaderboard_update_job_test.rb b/test/jobs/leaderboard_update_job_test.rb new file mode 100644 index 0000000..a43f4fd --- /dev/null +++ b/test/jobs/leaderboard_update_job_test.rb @@ -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 diff --git a/test/models/heartbeat_test.rb b/test/models/heartbeat_test.rb index 3398ed3..2a19bf4 100644 --- a/test/models/heartbeat_test.rb +++ b/test/models/heartbeat_test.rb @@ -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