discourse/app/models/user_stat.rb
Alan Guo Xiang Tan 3e4eac0fed
PERF: Enqueue Job::BackfillBadge in Jobs::BadgeGrant (#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.
2025-01-24 09:35:01 +08:00

352 lines
12 KiB
Ruby

# frozen_string_literal: true
class UserStat < ActiveRecord::Base
belongs_to :user
after_save :trigger_badges
self.ignored_columns = ["topic_reply_count"] # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
def self.ensure_consistency!(last_seen = 1.hour.ago)
reset_bounce_scores
update_distinct_badge_count
update_view_counts(last_seen)
update_first_unread(last_seen)
update_first_unread_pm(last_seen)
end
UPDATE_UNREAD_MINUTES_AGO = 10
UPDATE_UNREAD_USERS_LIMIT = 10_000
def self.update_first_unread_pm(last_seen, limit: UPDATE_UNREAD_USERS_LIMIT)
DB.exec(
<<~SQL,
UPDATE user_stats us
SET first_unread_pm_at = COALESCE(Z.min_date, :now)
FROM (
SELECT
u1.id user_id,
X.min_date
FROM users u1
LEFT JOIN (
SELECT
tau.user_id,
MIN(t.updated_at) min_date
FROM topic_allowed_users tau
INNER JOIN topics t ON t.id = tau.topic_id
INNER JOIN users u ON u.id = tau.user_id
LEFT JOIN topic_users tu ON t.id = tu.topic_id AND tu.user_id = tau.user_id
#{SiteSetting.whispers_allowed_groups_map.any? ? "LEFT JOIN group_users gu ON gu.group_id IN (:whisperers_group_ids) AND gu.user_id = u.id" : ""}
WHERE t.deleted_at IS NULL
AND t.archetype = :archetype
AND tu.last_read_post_number < CASE
WHEN u.admin OR u.moderator #{SiteSetting.whispers_allowed_groups_map.any? ? "OR gu.id IS NOT NULL" : ""}
THEN t.highest_staff_post_number
ELSE t.highest_post_number
END
AND (COALESCE(tu.notification_level, 1) >= 2)
AND tau.user_id IN (
SELECT id
FROM users
WHERE last_seen_at IS NOT NULL
AND last_seen_at > :last_seen
ORDER BY last_seen_at DESC
LIMIT :limit
)
GROUP BY tau.user_id
) AS X ON X.user_id = u1.id
WHERE u1.id IN (
SELECT id
FROM users
WHERE last_seen_at IS NOT NULL
AND last_seen_at > :last_seen
ORDER BY last_seen_at DESC
LIMIT :limit
)
) AS Z
WHERE us.user_id = Z.user_id
SQL
archetype: Archetype.private_message,
now: UPDATE_UNREAD_MINUTES_AGO.minutes.ago,
last_seen: last_seen,
limit: limit,
whisperers_group_ids: SiteSetting.whispers_allowed_groups_map,
)
end
def self.update_first_unread(last_seen, limit: UPDATE_UNREAD_USERS_LIMIT)
DB.exec(<<~SQL, min_date: last_seen, limit: limit, now: UPDATE_UNREAD_MINUTES_AGO.minutes.ago)
UPDATE user_stats us
SET first_unread_at = COALESCE(Y.min_date, :now)
FROM (
SELECT u1.id user_id,
X.min min_date
FROM users u1
LEFT JOIN
(SELECT u.id AS user_id,
min(topics.updated_at) min
FROM users u
LEFT JOIN topic_users tu ON tu.user_id = u.id
LEFT JOIN topics ON tu.topic_id = topics.id
JOIN user_stats AS us ON us.user_id = u.id
JOIN user_options AS uo ON uo.user_id = u.id
JOIN categories c ON c.id = topics.category_id
WHERE u.id IN (
SELECT id
FROM users
WHERE last_seen_at IS NOT NULL
AND last_seen_at > :min_date
ORDER BY last_seen_at DESC
LIMIT :limit
)
AND topics.archetype <> 'private_message'
AND (("topics"."deleted_at" IS NULL
AND tu.last_read_post_number < CASE
WHEN u.admin
OR u.moderator THEN topics.highest_staff_post_number
ELSE topics.highest_post_number
END
AND COALESCE(tu.notification_level, 1) >= 2)
OR (1=0))
AND (topics.visible
OR u.admin
OR u.moderator)
AND topics.deleted_at IS NULL
AND (NOT c.read_restricted
OR u.admin
OR category_id IN
(SELECT c2.id
FROM categories c2
JOIN category_groups cg ON cg.category_id = c2.id
JOIN group_users gu ON gu.user_id = u.id
AND cg.group_id = gu.group_id
WHERE c2.read_restricted ))
AND NOT EXISTS
(SELECT 1
FROM category_users cu
WHERE last_read_post_number IS NULL
AND cu.user_id = u.id
AND cu.category_id = topics.category_id
AND cu.notification_level = 0)
GROUP BY u.id,
u.username) AS X ON X.user_id = u1.id
WHERE u1.id IN
(
SELECT id
FROM users
WHERE last_seen_at IS NOT NULL
AND last_seen_at > :min_date
ORDER BY last_seen_at DESC
LIMIT :limit
)
) Y
WHERE Y.user_id = us.user_id
SQL
end
def self.reset_bounce_scores
UserStat
.where("reset_bounce_score_after < now()")
.where("bounce_score > 0")
.update_all(bounce_score: 0)
end
# Updates the denormalized view counts for all users
def self.update_view_counts(last_seen = 1.hour.ago)
# NOTE: we only update the counts for users we have seen in the last hour
# this avoids a very expensive query that may run on the entire user base
# we also ensure we only touch the table if data changes
# Update denormalized topics_entered
DB.exec(<<~SQL, seen_at: last_seen)
UPDATE user_stats SET topics_entered = X.c
FROM
(SELECT v.user_id, COUNT(topic_id) AS c
FROM topic_views AS v
WHERE v.user_id IN (
SELECT u1.id FROM users u1 where u1.last_seen_at > :seen_at
)
GROUP BY v.user_id) AS X
WHERE
X.user_id = user_stats.user_id AND
X.c <> topics_entered
SQL
# Update denormalized posts_read_count
DB.exec(<<~SQL, seen_at: last_seen)
WITH filtered_users AS (
SELECT id FROM users u
JOIN user_stats ON user_id = u.id
WHERE last_seen_at > :seen_at
AND posts_read_count < 10000
)
UPDATE user_stats SET posts_read_count = X.c
FROM (SELECT pt.user_id, COUNT(*) as c
FROM filtered_users AS u
JOIN post_timings AS pt ON pt.user_id = u.id
JOIN topics t ON t.id = pt.topic_id
WHERE t.archetype = 'regular'
AND t.deleted_at IS NULL
GROUP BY pt.user_id
) AS X
WHERE X.user_id = user_stats.user_id
AND X.c <> posts_read_count
SQL
end
def self.update_distinct_badge_count(user_ids = nil)
user_ids = user_ids.join(", ") if user_ids
sql = <<~SQL
UPDATE user_stats
SET distinct_badge_count = x.distinct_badge_count
FROM (
SELECT users.id user_id, COUNT(distinct user_badges.badge_id) distinct_badge_count
FROM users
LEFT JOIN user_badges ON user_badges.user_id = users.id
AND (user_badges.badge_id IN (SELECT id FROM badges WHERE enabled))
GROUP BY users.id
) x
WHERE user_stats.user_id = x.user_id AND user_stats.distinct_badge_count <> x.distinct_badge_count
#{"AND user_stats.user_id IN (#{user_ids})" if user_ids}
SQL
DB.exec sql
end
def update_distinct_badge_count
self.class.update_distinct_badge_count([self.user_id])
end
def self.update_draft_count(user_id = nil)
if user_id.present?
draft_count, has_topic_draft =
DB.query_single <<~SQL, user_id: user_id, new_topic: Draft::NEW_TOPIC
UPDATE user_stats
SET draft_count = (SELECT COUNT(*) FROM drafts WHERE user_id = :user_id)
WHERE user_id = :user_id
RETURNING draft_count, (SELECT 1 FROM drafts WHERE user_id = :user_id AND draft_key = :new_topic)
SQL
MessageBus.publish(
"/user-drafts/#{user_id}",
{ draft_count: draft_count, has_topic_draft: !!has_topic_draft },
user_ids: [user_id],
)
else
DB.exec <<~SQL
UPDATE user_stats
SET draft_count = new_user_stats.draft_count
FROM (SELECT user_stats.user_id, COUNT(drafts.id) draft_count
FROM user_stats
LEFT JOIN drafts ON user_stats.user_id = drafts.user_id
GROUP BY user_stats.user_id) new_user_stats
WHERE user_stats.user_id = new_user_stats.user_id
AND user_stats.draft_count <> new_user_stats.draft_count
SQL
end
end
# topic_reply_count is a count of posts in other users' topics
def calc_topic_reply_count!(start_time = nil)
sql = <<~SQL
SELECT COUNT(DISTINCT posts.topic_id) AS count
FROM posts
INNER JOIN topics ON topics.id = posts.topic_id
WHERE posts.user_id = ?
AND topics.user_id <> posts.user_id
AND posts.deleted_at IS NULL AND topics.deleted_at IS NULL
AND topics.archetype <> 'private_message'
#{start_time.nil? ? "" : "AND posts.created_at > ?"}
SQL
if start_time.nil?
DB.query_single(sql, self.user_id).first
else
DB.query_single(sql, self.user_id, start_time).first
end
end
def any_posts
user.posts.exists?
end
MAX_TIME_READ_DIFF = 100
# attempt to add total read time to user based on previous time this was called
def self.update_time_read!(id)
if last_seen = last_seen_cached(id)
diff = (Time.now.to_f - last_seen.to_f).round
if diff > 0 && diff < MAX_TIME_READ_DIFF
update_args = ["time_read = time_read + ?", diff]
UserStat.where(user_id: id).update_all(update_args)
UserVisit.where(user_id: id, visited_at: Time.zone.now.to_date).update_all(update_args)
end
end
cache_last_seen(id, Time.now.to_f)
end
def update_time_read!
UserStat.update_time_read!(id)
end
def reset_bounce_score!
update_columns(reset_bounce_score_after: nil, bounce_score: 0)
end
def self.last_seen_key(id)
# frozen
-"user-last-seen:#{id}"
end
def self.last_seen_cached(id)
Discourse.redis.get(last_seen_key(id))
end
def self.cache_last_seen(id, val)
Discourse.redis.setex(last_seen_key(id), MAX_TIME_READ_DIFF, val)
end
def update_pending_posts
update(pending_posts_count: user.pending_posts.count)
MessageBus.publish(
"/u/#{user.username_lower}/counters",
{ pending_posts_count: pending_posts_count },
user_ids: [user.id],
group_ids: [Group::AUTO_GROUPS[:staff]],
)
end
protected
def trigger_badges
BadgeGranter.queue_badge_grant(Badge::Trigger::UserChange, user: self.user)
end
end
# == Schema Information
#
# Table name: user_stats
#
# user_id :integer not null, primary key
# topics_entered :integer default(0), not null
# time_read :integer default(0), not null
# days_visited :integer default(0), not null
# posts_read_count :integer default(0), not null
# likes_given :integer default(0), not null
# likes_received :integer default(0), not null
# new_since :datetime not null
# read_faq :datetime
# first_post_created_at :datetime
# post_count :integer default(0), not null
# topic_count :integer default(0), not null
# bounce_score :float default(0.0), not null
# reset_bounce_score_after :datetime
# flags_agreed :integer default(0), not null
# flags_disagreed :integer default(0), not null
# flags_ignored :integer default(0), not null
# first_unread_at :datetime not null
# distinct_badge_count :integer default(0), not null
# first_unread_pm_at :datetime not null
# digest_attempted_at :datetime
# post_edits_count :integer
# draft_count :integer default(0), not null
# pending_posts_count :integer default(0), not null
#