Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/controllers/api/v1/leaderboard_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class Api::V1::LeaderboardController < ApplicationController
def daily
leaderboard = LeaderboardService.get(period: :daily, date: Date.current)
leaderboard = Leaderboard.fetch(period: :daily, date: Date.current)

if leaderboard.nil?
render json: { error: "Leaderboard is being generated" }, status: :service_unavailable
Expand All @@ -10,7 +10,7 @@ def daily
end

def weekly
leaderboard = LeaderboardService.get(period: :last_7_days, date: Date.current)
leaderboard = Leaderboard.fetch(period: :last_7_days, date: Date.current)

if leaderboard.nil?
render json: { error: "Leaderboard is being generated" }, status: :service_unavailable
Expand Down
19 changes: 4 additions & 15 deletions app/controllers/leaderboards_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def index
country = load_country_context
leaderboard_scope = validated_leaderboard_scope(country)

leaderboard = LeaderboardService.get(period: period_type, date: Date.current)
leaderboard = Leaderboard.fetch(period: period_type, date: Date.current)

render inertia: "Leaderboards/Index", props: {
period_type: period_type.to_s,
Expand Down Expand Up @@ -76,23 +76,12 @@ def entries_payload(leaderboard, scope, country)
active_projects = Cache::ActiveProjectsJob.perform_now

entries = payload[:entries].map do |e|
user = e[:user]
proj = active_projects&.dig(e[:user_id])
{
user_id: e[:user_id],
total_seconds: e[:total_seconds],
streak_count: e[:streak_count],
e.merge(
is_current_user: e[:user_id] == current_user&.id,
user: {
display_name: user[:display_name],
avatar_url: user[:avatar_url],
profile_path: user[:profile_path],
verified: user[:verified],
country_code: user[:country_code],
red: user[:red]
},
user: e[:user].except(:id),
active_project: proj ? { name: proj.project_name, repo_url: proj.repo_url } : nil
}
)
Comment thread
skyfallwastaken marked this conversation as resolved.
end

{
Expand Down
8 changes: 6 additions & 2 deletions app/jobs/cleanup_old_leaderboards_job.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
class CleanupOldLeaderboardsJob < ApplicationJob
queue_as :literally_whenever # fucking wild that this exists

# `RETAIN_DAYS = 2` keeps today + 2 prior days of boards (3 dates total)
# before reaping older ones. Boards with `start_date < (today - 2)` go.
RETAIN_DAYS = 2

def perform
cutoff = 2.days.ago.beginning_of_day
cutoff = RETAIN_DAYS.days.ago.to_date

old_leaderboards = Leaderboard.where("created_at < ?", cutoff)
old_leaderboards = Leaderboard.where(start_date: ...cutoff)
count = old_leaderboards.count
return if count.zero?

Expand Down
81 changes: 1 addition & 80 deletions app/jobs/leaderboard_update_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,85 +11,6 @@ class LeaderboardUpdateJob < ApplicationJob
)

def perform(period = :daily, date = Date.current, force_update: false)
date = LeaderboardDateRange.normalize_date(date, period)
build_leaderboard(date, period, force_update)
end

private

def build_leaderboard(date, period, force_update = false)
generation_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
board = ::Leaderboard.find_or_create_by!(
start_date: date,
period_type: period,
timezone_utc_offset: nil
)

return board if board.finished_generating_at.present? && !force_update

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
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 }

# Two-phase streak computation: query 8 days of data first (covers
# most users whose streaks are < 7 days), then extend to 31 days
# only for users whose streak maxed out the short window.
streaks = Heartbeat.daily_streaks_for_users(data.keys, start_date: 8.days.ago, exclude_browser_time: true)

needs_full_history = streaks.select { |_, streak| streak >= 6 }.keys
if needs_full_history.any?
needs_full_history.each { |id| Rails.cache.delete("user_streak_without_browser_v3_#{id}") }
full_streaks = Heartbeat.daily_streaks_for_users(needs_full_history, start_date: 31.days.ago, exclude_browser_time: true)
streaks.merge!(full_streaks)
end

entries = data.map do |user_id, seconds|
{
leaderboard_id: board.id,
user_id: user_id,
total_seconds: seconds,
streak_count: streaks[user_id] || 0,
created_at: timestamp,
updated_at: timestamp
}
end

LeaderboardEntry.upsert_all(entries, unique_by: %i[leaderboard_id user_id]) if entries.any?

if data.keys.any?
board.entries.where.not(user_id: data.keys).delete_all
else
board.entries.delete_all
end

finished_at = Time.current
generation_duration_seconds = [
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - generation_started_at).ceil,
1
].max

board.update!(
finished_generating_at: finished_at,
generation_duration_seconds: generation_duration_seconds
)
end

cache_key = LeaderboardCache.global_key(period, date)
LeaderboardCache.write(cache_key, board)
LeaderboardPageCache.warm(leaderboard: board)

Rails.logger.debug "Persisted leaderboard for #{period} with #{board.entries.count} entries"

board
Leaderboard.regenerate(period: period, date: date, force: force_update)
end
end
52 changes: 51 additions & 1 deletion app/models/leaderboard.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class Leaderboard < ApplicationRecord
GLOBAL_TIMEZONE = "UTC"
CACHE_EXPIRATION = 10.minutes

has_many :entries,
class_name: "LeaderboardEntry",
Expand All @@ -12,6 +12,42 @@ class Leaderboard < ApplicationRecord
last_7_days: 2
}

scope :ready, -> {
where.not(finished_generating_at: nil).where(deleted_at: nil, timezone_utc_offset: nil)
}

def self.fetch(period: :daily, date: Date.current)
period = period.to_sym
date = normalize_date(date)
key = cache_key(period, date)

if (cached = Rails.cache.read(key))
return cached
end

board = ready.find_by(start_date: date, period_type: period)
if board
write_cache(board, period: period, date: date)
return board
end

LeaderboardUpdateJob.perform_later(period, date)
nil
end

def self.regenerate(period:, date:, force: false)
Builder.new(period: period, date: date).call(force: force)
end

def self.normalize_date(date)
date = Date.current if date.blank?
date.is_a?(Date) ? date : Date.parse(date.to_s)
end

def self.write_cache(board, period:, date:)
Rails.cache.write(cache_key(period, date), board, expires_in: CACHE_EXPIRATION)
end

def finished_generating?
finished_generating_at.present?
end
Expand All @@ -20,11 +56,25 @@ def period_end_date
start_date
end

def range
case period_type.to_sym
when :last_7_days
((start_date - 6.days).beginning_of_day...start_date.end_of_day)
else
24.hours.ago...Time.current
end
end

def date_range_text
if last_7_days?
"#{(start_date - 6.days).strftime('%b %d')} - #{start_date.strftime('%b %d, %Y')}"
else
"Last 24 hours"
end
end

def self.cache_key(period, date)
"leaderboard_#{period}_#{date}"
end
private_class_method :cache_key
end
123 changes: 123 additions & 0 deletions app/models/leaderboard/builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
class Leaderboard
# Internal: builds (or rebuilds) the entries for a Leaderboard row.
# Use Leaderboard.regenerate(period:, date:) as the public entry point.
#
# Responsibilities, all in one place so the build invariants live together:
# - Find/create the persisted Leaderboard row for (period, date).
# - Aggregate eligible heartbeat durations over the board's range.
# - Compute streaks (with a two-phase short/long window optimization).
# - Upsert LeaderboardEntries and prune stale ones.
# - Mark the board finished and warm both the lookup + page caches.
class Builder
MIN_TOTAL_SECONDS = 60
SHORT_STREAK_WINDOW = 8.days
FULL_STREAK_WINDOW = 31.days
SHORT_STREAK_MAX = 6

def initialize(period:, date:)
@period = period.to_sym
@date = Leaderboard.normalize_date(date)
end

def call(force: false)
board = find_or_create_board
return board if board.finished_generating? && !force

Rails.logger.info "Building leaderboard for #{@period} on #{@date}"
generation_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)

ActiveRecord::Base.transaction do
upsert_entries(board)
finalize(board, generation_started)
end

Leaderboard.write_cache(board, period: @period, date: @date)
LeaderboardPageCache.warm(leaderboard: board)

Rails.logger.debug "Persisted leaderboard for #{@period} with #{board.entries.count} entries"
board
end

private

def find_or_create_board
Leaderboard.find_or_create_by!(
start_date: @date,
period_type: @period,
timezone_utc_offset: nil
)
end

def upsert_entries(board)
data = heartbeat_durations(board.range)
streaks = streaks_for(data.keys)
timestamp = Time.current

entries = data.map do |user_id, seconds|
{
leaderboard_id: board.id,
user_id: user_id,
total_seconds: seconds,
streak_count: streaks[user_id] || 0,
created_at: timestamp,
updated_at: timestamp
}
end

LeaderboardEntry.upsert_all(entries, unique_by: %i[leaderboard_id user_id]) if entries.any?

if data.keys.any?
board.entries.where.not(user_id: data.keys).delete_all
else
board.entries.delete_all
end
end

def heartbeat_durations(range)
eligible_users = User.where.not(github_uid: nil)
.where.not(trust_level: User.trust_levels[:red])

Heartbeat.where(user_id: eligible_users.select(:id), time: range)
.leaderboard_eligible
.group(:user_id)
.duration_seconds
.filter { |_, seconds| seconds > MIN_TOTAL_SECONDS }
end

# Two-phase streak computation: query a short window first (covers most
# users whose streaks are < 7 days), then extend to the full window only
# for users whose streak maxed out the short window.
def streaks_for(user_ids)
return {} if user_ids.empty?

streaks = Heartbeat.daily_streaks_for_users(
user_ids,
start_date: SHORT_STREAK_WINDOW.ago,
exclude_browser_time: true
)

maxed = streaks.select { |_, s| s >= SHORT_STREAK_MAX }.keys
return streaks if maxed.empty?

maxed.each { |id| Rails.cache.delete("user_streak_without_browser_v3_#{id}") }
streaks.merge(
Heartbeat.daily_streaks_for_users(
maxed,
start_date: FULL_STREAK_WINDOW.ago,
exclude_browser_time: true
)
)
end

def finalize(board, started_at)
duration = [
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).ceil,
1
].max
board.update!(
finished_generating_at: Time.current,
generation_duration_seconds: duration
)
end
end
end
25 changes: 0 additions & 25 deletions app/services/leaderboard_cache.rb

This file was deleted.

Loading
Loading