Skip to content

Commit f58b244

Browse files
tgxworldjancernik
authored andcommitted
PERF: Enqueue Job::BackfillBadge in Jobs::BadgeGrant (discourse#30945)
This commit updates the `Jobs::BadgeGrant` scheduled job to enqueue on `Job::BackfillBadge` regular job for each enabled badge on the site. The rationale for this change is that we started seeing the `Jobs::BadgeGrant` job taking hours on sites with lots of enabled badges as well as users because the job was backfilling all enabled badges serially within the job. This is bad as it means that a `mini_scheduler` thread is tied up by this job thus reducing the overall capacity of `mini_scheduler` for hours.
1 parent dfd6afc commit f58b244

File tree

9 files changed

+149
-60
lines changed

9 files changed

+149
-60
lines changed

app/controllers/user_badges_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def toggle_favorite
144144
UserBadge.where(user_id: user_badge.user_id, badge_id: user_badge.badge_id).update_all(
145145
is_favorite: !user_badge.is_favorite,
146146
)
147-
UserBadge.update_featured_ranks!(user_badge.user_id)
147+
UserBadge.update_featured_ranks!([user_badge.user_id])
148148
end
149149

150150
private

app/jobs/regular/backfill_badge.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class BackfillBadge < ::Jobs::Base
5+
sidekiq_options queue: "low"
6+
7+
def execute(args)
8+
return unless SiteSetting.enable_badges
9+
10+
badge = Badge.enabled.find_by(id: args[:badge_id])
11+
return unless badge
12+
13+
revoked_user_ids = Set.new
14+
granted_user_ids = Set.new
15+
16+
BadgeGranter.backfill(
17+
badge,
18+
revoked_callback: ->(user_ids) { revoked_user_ids.merge(user_ids) },
19+
granted_callback: ->(user_ids) { granted_user_ids.merge(user_ids) },
20+
)
21+
22+
affected_user_ids = (revoked_user_ids | granted_user_ids).to_a
23+
24+
BadgeGranter.revoke_ungranted_titles!(revoked_user_ids.to_a)
25+
UserBadge.ensure_consistency!(affected_user_ids)
26+
UserStat.update_distinct_badge_count(affected_user_ids)
27+
end
28+
end
29+
end

app/jobs/scheduled/badge_grant.rb

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,7 @@ def self.run
1010

1111
def execute(args)
1212
return unless SiteSetting.enable_badges
13-
14-
Badge.enabled.find_each do |b|
15-
begin
16-
BadgeGranter.backfill(b)
17-
rescue => ex
18-
# TODO - expose errors in UI
19-
Discourse.handle_job_exception(
20-
ex,
21-
error_context({}, code_desc: "Exception granting badges", extra: { badge_id: b.id }),
22-
)
23-
end
24-
end
25-
26-
BadgeGranter.revoke_ungranted_titles!
27-
UserBadge.ensure_consistency! # Badge granter sometimes uses raw SQL, so hooks do not run. Clean up data
28-
UserStat.update_distinct_badge_count
13+
Badge.enabled.pluck(:id).each { |badge_id| Jobs.enqueue(:backfill_badge, badge_id: badge_id) }
2914
end
3015
end
3116
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class EnsureBadgesConsistency < ::Jobs::Scheduled
5+
every 1.day
6+
7+
def execute(args)
8+
return unless SiteSetting.enable_badges
9+
10+
BadgeGranter.revoke_ungranted_titles!
11+
UserBadge.ensure_consistency!
12+
UserStat.update_distinct_badge_count
13+
end
14+
end
15+
end

app/models/user_badge.rb

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,32 +56,36 @@ class UserBadge < ActiveRecord::Base
5656

5757
after_create do
5858
Badge.increment_counter "grant_count", self.badge_id
59-
UserStat.update_distinct_badge_count self.user_id
60-
UserBadge.update_featured_ranks! self.user_id
59+
user_ids = [self.user_id]
60+
UserStat.update_distinct_badge_count(user_ids)
61+
UserBadge.update_featured_ranks!(user_ids)
6162
self.trigger_user_badge_granted_event
6263
end
6364

6465
after_destroy do
6566
Badge.decrement_counter "grant_count", self.badge_id
66-
UserStat.update_distinct_badge_count self.user_id
67-
UserBadge.update_featured_ranks! self.user_id
67+
user_ids = [self.user_id]
68+
UserStat.update_distinct_badge_count(user_ids)
69+
UserBadge.update_featured_ranks!(user_ids)
6870

6971
DiscourseEvent.trigger(:user_badge_removed, self.badge_id, self.user_id)
7072
DiscourseEvent.trigger(:user_badge_revoked, user_badge: self)
7173
end
7274

73-
def self.ensure_consistency!
74-
self.update_featured_ranks!
75+
def self.ensure_consistency!(user_ids = nil)
76+
self.update_featured_ranks!(user_ids)
7577
end
7678

77-
def self.update_featured_ranks!(user_id = nil)
79+
def self.update_featured_ranks!(user_ids = nil)
80+
user_ids = user_ids.join(", ") if user_ids
81+
7882
query = <<~SQL
7983
WITH featured_tl_badge AS -- Find the best trust level badge for each user
8084
(
8185
SELECT user_id, max(badge_id) as badge_id
8286
FROM user_badges
8387
WHERE badge_id IN (1,2,3,4)
84-
#{"AND user_id = #{user_id.to_i}" if user_id}
88+
#{"AND user_id IN (#{user_ids})" if user_ids}
8589
GROUP BY user_id
8690
),
8791
ranks AS ( -- Take all user badges, group by user_id and badge_id, and calculate a rank for each one
@@ -101,7 +105,7 @@ def self.update_featured_ranks!(user_id = nil)
101105
FROM user_badges
102106
INNER JOIN badges ON badges.id = user_badges.badge_id
103107
LEFT JOIN featured_tl_badge ON featured_tl_badge.user_id = user_badges.user_id AND featured_tl_badge.badge_id = user_badges.badge_id
104-
#{"WHERE user_badges.user_id = #{user_id.to_i}" if user_id}
108+
#{"WHERE user_badges.user_id IN (#{user_ids})" if user_ids}
105109
GROUP BY user_badges.user_id, user_badges.badge_id
106110
)
107111
-- Now use that data to update the featured_rank column

app/models/user_stat.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ def self.update_view_counts(last_seen = 1.hour.ago)
192192
SQL
193193
end
194194

195-
def self.update_distinct_badge_count(user_id = nil)
195+
def self.update_distinct_badge_count(user_ids = nil)
196+
user_ids = user_ids.join(", ") if user_ids
197+
196198
sql = <<~SQL
197199
UPDATE user_stats
198200
SET distinct_badge_count = x.distinct_badge_count
@@ -204,15 +206,14 @@ def self.update_distinct_badge_count(user_id = nil)
204206
GROUP BY users.id
205207
) x
206208
WHERE user_stats.user_id = x.user_id AND user_stats.distinct_badge_count <> x.distinct_badge_count
209+
#{"AND user_stats.user_id IN (#{user_ids})" if user_ids}
207210
SQL
208211

209-
sql = sql + " AND user_stats.user_id = #{user_id.to_i}" if user_id
210-
211212
DB.exec sql
212213
end
213214

214215
def update_distinct_badge_count
215-
self.class.update_distinct_badge_count(self.user_id)
216+
self.class.update_distinct_badge_count([self.user_id])
216217
end
217218

218219
def self.update_draft_count(user_id = nil)

app/services/badge_granter.rb

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def self.mass_grant(badge, user, count:)
110110
WHERE notification_id IS NULL AND user_id = :user_id AND badge_id = :badge_id
111111
SQL
112112

113-
UserBadge.update_featured_ranks!(user.id)
113+
UserBadge.update_featured_ranks!([user.id])
114114
end
115115
end
116116

@@ -404,8 +404,9 @@ def self.backfill(badge, opts = nil)
404404
post_clause = badge.target_posts ? "AND (q.post_id = ub.post_id OR NOT :multiple_grant)" : ""
405405
post_id_field = badge.target_posts ? "q.post_id" : "NULL"
406406

407-
sql = <<~SQL
408-
DELETE FROM user_badges
407+
if badge.auto_revoke && full_backfill
408+
sql = <<~SQL
409+
DELETE FROM user_badges
409410
WHERE id IN (
410411
SELECT ub.id
411412
FROM user_badges ub
@@ -415,17 +416,22 @@ def self.backfill(badge, opts = nil)
415416
#{post_clause}
416417
WHERE ub.badge_id = :id AND q.user_id IS NULL
417418
)
418-
SQL
419+
RETURNING user_badges.user_id
420+
SQL
419421

420-
if badge.auto_revoke && full_backfill
421-
DB.exec(
422-
sql,
423-
id: badge.id,
424-
post_ids: [-1],
425-
user_ids: [-2],
426-
backfill: true,
427-
multiple_grant: true, # cheat here, cause we only run on backfill and are deleting
428-
)
422+
rows =
423+
DB.query(
424+
sql,
425+
id: badge.id,
426+
post_ids: [-1],
427+
user_ids: [-2],
428+
backfill: true,
429+
multiple_grant: true, # cheat here, cause we only run on backfill and are deleting
430+
)
431+
432+
if (revoked_callback = opts&.dig(:revoked_callback)) && rows.size > 0
433+
revoked_callback.call(rows.map { |r| r.user_id })
434+
end
429435
end
430436

431437
sql = <<~SQL
@@ -465,34 +471,41 @@ def self.backfill(badge, opts = nil)
465471
return
466472
end
467473

468-
builder
469-
.query(
474+
rows =
475+
builder.query(
470476
id: badge.id,
471477
multiple_grant: badge.multiple_grant,
472478
backfill: full_backfill,
473479
post_ids: post_ids || [-2],
474480
user_ids: user_ids || [-2],
475481
)
476-
.each do |row|
477-
next if suppress_notification?(badge, row.granted_at, row.skip_new_user_tips)
478-
next if row.staff && badge.awarded_for_trust_level?
479482

480-
notification = send_notification(row.user_id, row.username, row.locale, badge)
481-
UserBadge.trigger_user_badge_granted_event(badge.id, row.user_id)
483+
if (granted_callback = opts&.dig(:granted_callback)) && rows.size > 0
484+
granted_callback.call(rows.map { |r| r.user_id })
485+
end
482486

483-
DB.exec(
484-
"UPDATE user_badges SET notification_id = :notification_id WHERE id = :id",
485-
notification_id: notification.id,
486-
id: row.id,
487-
)
488-
end
487+
rows.each do |row|
488+
next if suppress_notification?(badge, row.granted_at, row.skip_new_user_tips)
489+
next if row.staff && badge.awarded_for_trust_level?
490+
491+
notification = send_notification(row.user_id, row.username, row.locale, badge)
492+
UserBadge.trigger_user_badge_granted_event(badge.id, row.user_id)
493+
494+
DB.exec(
495+
"UPDATE user_badges SET notification_id = :notification_id WHERE id = :id",
496+
notification_id: notification.id,
497+
id: row.id,
498+
)
499+
end
489500

490501
badge.reset_grant_count!
491502
rescue => e
492503
raise GrantError, "Failed to backfill '#{badge.name}' badge: #{opts}. Reason: #{e.message}"
493504
end
494505

495-
def self.revoke_ungranted_titles!
506+
def self.revoke_ungranted_titles!(user_ids = nil)
507+
user_ids = user_ids.join(", ") if user_ids
508+
496509
DB.exec <<~SQL
497510
UPDATE users u
498511
SET title = ''
@@ -509,6 +522,7 @@ def self.revoke_ungranted_titles!
509522
AND b.allow_title
510523
AND b.enabled
511524
)
525+
#{user_ids.present? ? "AND u.id IN (:user_ids)" : ""}
512526
SQL
513527

514528
DB.exec <<~SQL
@@ -518,6 +532,7 @@ def self.revoke_ungranted_titles!
518532
WHERE up.user_id = u.id
519533
AND (u.title IS NULL OR u.title = '')
520534
AND up.granted_title_badge_id IS NOT NULL
535+
#{user_ids.present? ? "AND up.user_id IN (:user_ids)" : ""}
521536
SQL
522537
end
523538

spec/models/user_badge_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,14 @@
121121
it "can ensure consistency per user" do
122122
user_badge_tl2.update_column(:featured_rank, 20) # Update without hooks
123123
expect(user_badge_tl2.reload.featured_rank).to eq(20) # Double check
124-
UserBadge.update_featured_ranks! user.id
124+
UserBadge.update_featured_ranks!([user.id])
125125
expect(user_badge_tl2.reload.featured_rank).to eq(1)
126126
end
127127

128128
it "can ensure consistency for all users" do
129129
user_badge_tl2.update_column(:featured_rank, 20) # Update without hooks
130130
expect(user_badge_tl2.reload.featured_rank).to eq(20) # Double check
131-
UserBadge.update_featured_ranks!
131+
UserBadge.update_featured_ranks!([user.id])
132132
expect(user_badge_tl2.reload.featured_rank).to eq(1)
133133
end
134134
end

spec/services/badge_granter_spec.rb

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115
end
116116
end
117117

118-
describe "backfill" do
118+
describe ".backfill" do
119119
it "has no broken badge queries" do
120120
Badge.all.each { |b| BadgeGranter.backfill(b) }
121121
end
@@ -211,6 +211,46 @@
211211

212212
expect(UserBadge.where(user_id: user_id).count).to eq(0)
213213
end
214+
215+
it "auto revokes badges from users when badge is set to auto revoke and user no longer satisfy the badge's query" do
216+
user.update!(username: "cool_username")
217+
218+
badge_for_having_cool_username =
219+
Fabricate(
220+
:badge,
221+
query:
222+
"SELECT users.id user_id, CURRENT_TIMESTAMP granted_at FROM users WHERE users.username = 'cool_username'",
223+
auto_revoke: true,
224+
)
225+
226+
granted_user_ids = []
227+
228+
BadgeGranter.backfill(
229+
badge_for_having_cool_username,
230+
granted_callback: ->(user_ids) { granted_user_ids.concat(user_ids) },
231+
)
232+
233+
expect(granted_user_ids).to eq([user.id])
234+
235+
expect(
236+
UserBadge.exists?(user_id: user.id, badge_id: badge_for_having_cool_username.id),
237+
).to eq(true)
238+
239+
user.update!(username: "not_cool_username")
240+
241+
revoked_user_ids = []
242+
243+
BadgeGranter.backfill(
244+
badge_for_having_cool_username,
245+
revoked_callback: ->(user_ids) { revoked_user_ids.concat(user_ids) },
246+
)
247+
248+
expect(revoked_user_ids).to eq([user.id])
249+
250+
expect(
251+
UserBadge.exists?(user_id: user.id, badge_id: badge_for_having_cool_username.id),
252+
).to eq(false)
253+
end
214254
end
215255

216256
describe "grant" do

0 commit comments

Comments
 (0)