Skip to content

Commit 47f8c5f

Browse files
fix: adjust performance issues
1 parent 1706249 commit 47f8c5f

6 files changed

Lines changed: 184 additions & 86 deletions

File tree

app/controllers/api/v1/analytics/laning_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ def show
1616
stats = PlayerMatchStat.joins(:match)
1717
.includes(:match)
1818
.where(player: player, match: { organization: current_organization })
19-
.order('matches.game_start DESC')
19+
.order('"match"."game_start" DESC')
2020
.limit(20)
2121

2222
games = stats.count
23-
wins = stats.joins(:match).where(matches: { victory: true }).count
23+
wins = stats.where(match: { victory: true }).count
2424

2525
laning_data = {
2626
player: PlayerSerializer.render_as_hash(player),

app/controllers/api/v1/analytics/vision_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def show
1515
stats = PlayerMatchStat.joins(:match)
1616
.includes(:match)
1717
.where(player: player, match: { organization: current_organization })
18-
.order('matches.game_start DESC')
18+
.order('"match"."game_start" DESC')
1919
.limit(20)
2020

2121
vision_data = {

app/models/match.rb

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,21 @@ def score_display
8383
end
8484

8585
def kda_summary
86-
stats = player_match_stats.includes(:player)
87-
total_kills = stats.sum(:kills)
88-
total_deaths = stats.sum(:deaths)
89-
total_assists = stats.sum(:assists)
86+
# Single aggregate query instead of 3 separate SUM calls
87+
row = player_match_stats
88+
.select('SUM(kills) AS k, SUM(deaths) AS d, SUM(assists) AS a')
89+
.take
9090

91-
deaths = total_deaths.zero? ? 1 : total_deaths
92-
kda = (total_kills + total_assists).to_f / deaths
91+
total_kills = row&.k.to_i
92+
total_deaths = row&.d.to_i
93+
total_assists = row&.a.to_i
94+
deaths_divisor = total_deaths.zero? ? 1 : total_deaths
9395

9496
{
95-
kills: total_kills,
96-
deaths: total_deaths,
97+
kills: total_kills,
98+
deaths: total_deaths,
9799
assists: total_assists,
98-
kda: kda.round(2)
100+
kda: ((total_kills + total_assists).to_f / deaths_divisor).round(2)
99101
}
100102
end
101103

app/modules/analytics/services/performance_analytics_service.rb

Lines changed: 80 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,38 @@ def initialize(matches, players)
2323

2424
# Calculates complete performance data
2525
#
26+
# When player_id is provided, skips all team-level aggregations (overview,
27+
# trends, role breakdown, best performers) since the frontend only uses
28+
# player_stats in that context. This avoids 15+ unnecessary DB queries
29+
# per player-specific request.
30+
#
2631
# @param player_id [Integer, nil] Optional player ID for individual stats
2732
# @param all_players [ActiveRecord::Relation, nil] Scope to resolve the individual player
2833
# from. Defaults to @players (active only). Pass the full org scope when you want to
2934
# allow individual stats for inactive/bench/trial players too.
3035
# @return [Hash] Performance analytics data
3136
def calculate_performance_data(player_id: nil, all_players: nil)
32-
data = {
33-
overview: team_overview,
34-
win_rate_trend: win_rate_trend,
35-
performance_by_role: performance_by_role,
36-
best_performers: best_performers,
37-
match_type_breakdown: match_type_breakdown
38-
}
39-
4037
if player_id
4138
# Use the broader scope when provided so bench/trial players can still be looked up
4239
lookup_scope = all_players || @players
4340
player = lookup_scope.find_by(id: player_id)
44-
data[:player_stats] = player_statistics(player) if player
41+
return {
42+
overview: {},
43+
win_rate_trend: [],
44+
performance_by_role: [],
45+
best_performers: [],
46+
match_type_breakdown: [],
47+
player_stats: player ? player_statistics(player) : nil
48+
}
4549
end
4650

47-
data
51+
{
52+
overview: team_overview,
53+
win_rate_trend: win_rate_trend,
54+
performance_by_role: performance_by_role,
55+
best_performers: best_performers,
56+
match_type_breakdown: match_type_breakdown
57+
}
4858
rescue StandardError => e
4959
Rails.logger.error("Error in calculate_performance_data: #{e.message}")
5060
Rails.logger.error(e.backtrace.join("\n"))
@@ -59,29 +69,66 @@ def calculate_performance_data(player_id: nil, all_players: nil)
5969

6070
private
6171

62-
# Calculates team overview statistics
72+
# Calculates team overview statistics using 2 aggregated SQL queries
73+
# instead of 10+ individual ones.
6374
def team_overview
64-
stats = PlayerMatchStat.where(match: @matches)
65-
build_team_overview_hash(stats)
75+
build_team_overview_hash
6676
rescue StandardError => e
6777
log_error("team_overview", e)
6878
{}
6979
end
7080

71-
def build_team_overview_hash(stats)
81+
def build_team_overview_hash
82+
# Query 1: all match-level aggregates in a single pass
83+
match_row = @matches
84+
.select(
85+
'COUNT(*) AS total',
86+
'COUNT(*) FILTER (WHERE victory) AS wins',
87+
'COUNT(*) FILTER (WHERE NOT victory) AS losses',
88+
'ROUND(AVG(game_duration)) AS avg_duration'
89+
)
90+
.take
91+
92+
total = match_row&.total.to_i
93+
wins = match_row&.wins.to_i
94+
losses = match_row&.losses.to_i
95+
win_rate = total.zero? ? 0.0 : ((wins.to_f / total) * 100).round(1)
96+
97+
# Query 2: all stat-level aggregates in a single pass, including sums for KDA
98+
stat_row = PlayerMatchStat
99+
.where(match: @matches)
100+
.select(
101+
'AVG(kills) AS avg_kills',
102+
'AVG(deaths) AS avg_deaths',
103+
'AVG(assists) AS avg_assists',
104+
'AVG(gold_earned) AS avg_gold',
105+
'AVG(damage_dealt_total) AS avg_damage',
106+
'AVG(vision_score) AS avg_vision',
107+
'SUM(kills) AS total_kills',
108+
'SUM(deaths) AS total_deaths',
109+
'SUM(assists) AS total_assists'
110+
)
111+
.take
112+
113+
total_kills = stat_row&.total_kills.to_i
114+
total_deaths = stat_row&.total_deaths.to_i
115+
total_assists = stat_row&.total_assists.to_i
116+
deaths_divisor = total_deaths.zero? ? 1 : total_deaths
117+
avg_kda = ((total_kills + total_assists).to_f / deaths_divisor).round(2)
118+
72119
{
73-
total_matches: @matches.count || 0,
74-
wins: @matches.victories.count || 0,
75-
losses: @matches.defeats.count || 0,
76-
win_rate: calculate_win_rate(@matches),
77-
avg_game_duration: @matches.average(:game_duration)&.round(0) || 0,
78-
avg_kda: calculate_avg_kda(stats),
79-
avg_kills_per_game: stats.average(:kills)&.round(1) || 0,
80-
avg_deaths_per_game: stats.average(:deaths)&.round(1) || 0,
81-
avg_assists_per_game: stats.average(:assists)&.round(1) || 0,
82-
avg_gold_per_game: stats.average(:gold_earned)&.round(0) || 0,
83-
avg_damage_per_game: stats.average(:damage_dealt_total)&.round(0) || 0,
84-
avg_vision_score: stats.average(:vision_score)&.round(1) || 0
120+
total_matches: total,
121+
wins: wins,
122+
losses: losses,
123+
win_rate: win_rate,
124+
avg_game_duration: match_row&.avg_duration.to_i,
125+
avg_kda: avg_kda,
126+
avg_kills_per_game: stat_row&.avg_kills.to_f.round(1),
127+
avg_deaths_per_game: stat_row&.avg_deaths.to_f.round(1),
128+
avg_assists_per_game: stat_row&.avg_assists.to_f.round(1),
129+
avg_gold_per_game: stat_row&.avg_gold.to_f.round(0),
130+
avg_damage_per_game: stat_row&.avg_damage.to_f.round(0),
131+
avg_vision_score: stat_row&.avg_vision.to_f.round(1)
85132
}
86133
end
87134

@@ -147,15 +194,14 @@ def performance_by_role
147194
end
148195

149196
# Identifies top performing players
150-
# Single GROUP BY query instead of 1+6N per-player queries
197+
# Single GROUP BY query instead of 1+6N per-player queries.
198+
# Uses subqueries instead of pluck to avoid loading hundreds of IDs into Ruby.
151199
def best_performers
152-
player_ids = @players.pluck(:id)
153-
match_ids = @matches.pluck(:id)
154-
return [] if player_ids.empty? || match_ids.empty?
200+
return [] if @players.none? || @matches.none?
155201

156202
aggregated = PlayerMatchStat
157203
.joins(:match)
158-
.where(player_id: player_ids, match_id: match_ids)
204+
.where(player_id: @players.select(:id), match_id: @matches.select(:id))
159205
.group(:player_id)
160206
.select(
161207
'player_id',
@@ -298,22 +344,17 @@ def calculate_farm_share(stats)
298344
end
299345

300346
def sum_player_cs(stats)
301-
stats.sum(
302-
"COALESCE(cs, minions_killed + COALESCE(jungle_minions_killed, 0), 0)"
303-
).to_i
347+
stats.sum("COALESCE(cs, 0)").to_i
304348
end
305349

306350
def calculate_team_cs(stats)
307-
match_ids = stats.pluck(:match_id).uniq
308-
return 0 if match_ids.empty?
309-
310351
player = stats.first&.player
311352
return 0 unless player&.organization_id
312353

313354
PlayerMatchStat
314355
.joins(:player)
315-
.where(match_id: match_ids, players: { organization_id: player.organization_id })
316-
.sum("COALESCE(cs, minions_killed + COALESCE(jungle_minions_killed, 0), 0)")
356+
.where(match_id: stats.select(:match_id), players: { organization_id: player.organization_id })
357+
.sum("COALESCE(cs, 0)")
317358
.to_i
318359
end
319360

app/modules/dashboard/controllers/dashboard_controller.rb

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@ def index
1818
end
1919

2020
def stats
21-
cache_key = "dashboard_stats_#{current_organization.id}_#{current_organization.updated_at.to_i}"
22-
cached_stats = Rails.cache.fetch(cache_key, expires_in: 5.minutes) { calculate_stats }
23-
render_success(cached_stats)
21+
# calculate_stats now caches internally — no need to wrap again here
22+
render_success(calculate_stats)
2423
end
2524

2625
def activities
@@ -46,30 +45,75 @@ def schedule
4645

4746
private
4847

48+
# Returns dashboard stats, cached per-org for 5 minutes.
49+
# Both the `index` and `stats` actions go through here so the cache
50+
# is shared — only one thread ever runs the queries for a given org.
4951
def calculate_stats
52+
Rails.cache.fetch("dashboard_stats_v2_#{current_organization.id}", expires_in: 5.minutes) do
53+
compute_dashboard_stats
54+
end
55+
end
56+
57+
# Runs the actual DB queries — called only on cache miss.
58+
# Reduces ~12 individual queries down to 6 by using SQL aggregates.
59+
def compute_dashboard_stats
5060
matches = organization_scoped(Match).recent(30)
51-
players = organization_scoped(Player).active
61+
62+
# Query 1: match aggregates — total, wins, losses in one pass
63+
match_row = matches.select(
64+
'COUNT(*) AS total',
65+
'COUNT(*) FILTER (WHERE victory) AS wins',
66+
'COUNT(*) FILTER (WHERE NOT victory) AS losses'
67+
).take
68+
69+
total_matches = match_row&.total.to_i
70+
wins = match_row&.wins.to_i
71+
losses = match_row&.losses.to_i
72+
win_rate = total_matches.zero? ? 0.0 : ((wins.to_f / total_matches) * 100).round(1)
73+
74+
# Query 2: player counts — total + active in one pass
75+
# (organization_scoped already adds deleted_at IS NULL)
76+
player_row = organization_scoped(Player).select(
77+
"COUNT(*) AS total",
78+
"COUNT(*) FILTER (WHERE status = 'active') AS active_count"
79+
).take
80+
81+
# Query 3: avg KDA — single aggregate instead of Exists? + 3× SUM
82+
kda_row = PlayerMatchStat
83+
.where(match: matches)
84+
.select('SUM(kills) AS k, SUM(deaths) AS d, SUM(assists) AS a')
85+
.take
86+
k = kda_row&.k.to_i; d = kda_row&.d.to_i; a = kda_row&.a.to_i
87+
avg_kda = ((k + a).to_f / (d.zero? ? 1 : d)).round(2)
88+
89+
# Query 4: recent form (5 records — small, fine as-is)
90+
recent_form = calculate_recent_form(matches.order(game_start: :desc).limit(5))
91+
92+
# Query 5: goal counts — one GROUP BY instead of two COUNTs
93+
goals_by_status = organization_scoped(TeamGoal).group(:status).count
94+
95+
# Query 6: upcoming matches
96+
upcoming_matches = organization_scoped(Schedule)
97+
.where('start_time >= ? AND event_type = ?', Time.current, 'match')
98+
.count
5299

53100
{
54-
total_players: players.count,
55-
active_players: players.where(status: 'active').count,
56-
total_matches: matches.count,
57-
wins: matches.victories.count,
58-
losses: matches.defeats.count,
59-
win_rate: calculate_win_rate(matches),
60-
recent_form: calculate_recent_form(matches.order(game_start: :desc).limit(5)),
61-
avg_kda: calculate_avg_kda(PlayerMatchStat.where(match: matches)),
62-
active_goals: organization_scoped(TeamGoal).active.count,
63-
completed_goals: organization_scoped(TeamGoal).where(status: 'completed').count,
64-
upcoming_matches: organization_scoped(Schedule).where('start_time >= ? AND event_type = ?', Time.current,
65-
'match').count
101+
total_players: player_row&.total.to_i,
102+
active_players: player_row&.active_count.to_i,
103+
total_matches: total_matches,
104+
wins: wins,
105+
losses: losses,
106+
win_rate: win_rate,
107+
recent_form: recent_form,
108+
avg_kda: avg_kda,
109+
active_goals: goals_by_status['active'].to_i,
110+
completed_goals: goals_by_status['completed'].to_i,
111+
upcoming_matches: upcoming_matches
66112
}
67113
end
68114

69-
# Methods moved to Analytics::Concerns::AnalyticsCalculations
70-
# - calculate_win_rate
71-
# - calculate_recent_form
72-
# - calculate_avg_kda (renamed from calculate_average_kda)
115+
# Methods from Analytics::Concerns::AnalyticsCalculations:
116+
# - calculate_recent_form (used above for recent_form)
73117

74118
def recent_matches_data
75119
matches = organization_scoped(Match)

app/serializers/organization_serializer.rb

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,25 +56,36 @@ class OrganizationSerializer < Blueprinter::Base
5656
end
5757

5858
field :statistics do |org|
59-
begin
59+
# Cache for 2 minutes to avoid re-running these COUNT queries on every
60+
# /auth/me call (the frontend fires this endpoint 3-4 times per page load).
61+
Rails.cache.fetch("org_statistics_v1_#{org.id}", expires_in: 2.minutes) do
62+
# Single query for both total and active player counts
63+
player_row = org.players
64+
.where(deleted_at: nil)
65+
.select(
66+
"COUNT(*) AS total_count",
67+
"COUNT(*) FILTER (WHERE status = 'active') AS active_count"
68+
)
69+
.take
70+
6071
{
61-
total_players: org.cached_players_count,
62-
active_players: org.players.where(deleted_at: nil, status: 'active').count,
63-
total_matches: org.matches.count,
72+
total_players: player_row&.total_count.to_i,
73+
active_players: player_row&.active_count.to_i,
74+
total_matches: org.matches.count,
6475
recent_matches: org.cached_monthly_matches_count,
65-
total_users: org.users.count
66-
}
67-
rescue => e
68-
Rails.logger.error("OrganizationSerializer statistics error: #{e.class} - #{e.message}")
69-
Rails.logger.error(e.backtrace&.first(5)&.join("\n"))
70-
{
71-
total_players: 0,
72-
active_players: 0,
73-
total_matches: 0,
74-
recent_matches: 0,
75-
total_users: 0
76+
total_users: org.users.count
7677
}
7778
end
79+
rescue => e
80+
Rails.logger.error("OrganizationSerializer statistics error: #{e.class} - #{e.message}")
81+
Rails.logger.error(e.backtrace&.first(5)&.join("\n"))
82+
{
83+
total_players: 0,
84+
active_players: 0,
85+
total_matches: 0,
86+
recent_matches: 0,
87+
total_users: 0
88+
}
7889
end
7990

8091
# Tier features and capabilities

0 commit comments

Comments
 (0)