Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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, period)
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, _period)
date = Date.current if date.blank?
date.is_a?(Date) ? date : Date.parse(date.to_s)
end
Comment thread
skyfallwastaken marked this conversation as resolved.
Outdated

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, @period)
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