PERF: Dematerialize topic_reply_count (#9769)

PERF: Dematerialize topic_reply_count (#9769)

  • PERF: Dematerialize topic_reply_count

It’s only ever used for trust level promotions that run daily, or compared to 0. We don’t need to track it on every post creation.

  • UX: Add symbol in TL3 report if topic reply count is capped

  • DEV: Drop user_stats.topic_reply_count column

diff --git a/app/assets/javascripts/admin/models/tl3-requirements.js b/app/assets/javascripts/admin/models/tl3-requirements.js
index 424aea4..6331333 100644
--- a/app/assets/javascripts/admin/models/tl3-requirements.js
+++ b/app/assets/javascripts/admin/models/tl3-requirements.js
@@ -12,6 +12,11 @@ export default EmberObject.extend({
     return Math.round((minDaysVisited * 100) / timePeriod);
   },
 
+  @discourseComputed("num_topics_replied_to", "min_topics_replied_to")
+  capped_topics_replied_to(numReplied, minReplied) {
+    return numReplied > minReplied;
+  },
+
   @discourseComputed(
     "days_visited",
     "min_days_visited",
diff --git a/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs b/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs
index 6a76b43..de71c0b 100644
--- a/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs
+++ b/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs
@@ -33,7 +33,7 @@
       <tr>
         <th>{{i18n "admin.user.tl3_requirements.topics_replied_to"}}</th>
         <td>{{check-icon model.tl3Requirements.met.topics_replied_to}}</td>
-        <td>{{model.tl3Requirements.num_topics_replied_to}}</td>
+        <td>{{#if model.tl3Requirements.capped_topics_replied_to}}&ge; {{/if}}{{model.tl3Requirements.num_topics_replied_to}}</td>
         <td>{{model.tl3Requirements.min_topics_replied_to}}</td>
       </tr>
       <tr>
diff --git a/app/assets/javascripts/discourse/app/components/notification-consent-banner.js b/app/assets/javascripts/discourse/app/components/notification-consent-banner.js
index 97ee9b0..0ef2aca 100644
--- a/app/assets/javascripts/discourse/app/components/notification-consent-banner.js
+++ b/app/assets/javascripts/discourse/app/components/notification-consent-banner.js
@@ -20,21 +20,19 @@ export default DesktopNotificationConfig.extend({
     "isNotSupported",
     "isEnabled",
     "bannerDismissed",
-    "currentUser.reply_count",
-    "currentUser.topic_count"
+    "currentUser.any_posts"
   )
   showNotificationPromptBanner(
     isNotSupported,
     isEnabled,
     bannerDismissed,
-    replyCount,
-    topicCount
+    anyPosts
   ) {
     return (
       this.siteSettings.push_notifications_prompt &&
       !isNotSupported &&
       this.currentUser &&
-      replyCount + topicCount > 0 &&
+      anyPosts &&
       Notification.permission !== "denied" &&
       Notification.permission !== "granted" &&
       !isEnabled &&
diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js
index cd3d357..4890db3 100644
--- a/app/assets/javascripts/discourse/app/controllers/composer.js
+++ b/app/assets/javascripts/discourse/app/controllers/composer.js
@@ -732,12 +732,7 @@ export default Controller.extend({
 
         this.close();
 
-        const currentUser = this.currentUser;
-        if (composer.creatingTopic) {
-          currentUser.set("topic_count", currentUser.topic_count + 1);
-        } else {
-          currentUser.set("reply_count", currentUser.reply_count + 1);
-        }
+        this.currentUser.set("any_posts", true);
 
         const post = result.target;
         if (post && !staged) {
diff --git a/app/jobs/scheduled/tl3_promotions.rb b/app/jobs/scheduled/tl3_promotions.rb
index 7dcce77..af1d635 100644
--- a/app/jobs/scheduled/tl3_promotions.rb
+++ b/app/jobs/scheduled/tl3_promotions.rb
@@ -34,7 +34,6 @@ module Jobs
         ).where.not(id: demoted_user_ids)
         .joins(:user_stat)
         .where("user_stats.days_visited >= ?", SiteSetting.tl3_requires_days_visited)
-        .where("user_stats.topic_reply_count >= ?", SiteSetting.tl3_requires_topics_replied_to)
         .where("user_stats.topics_entered >= ?", SiteSetting.tl3_requires_topics_viewed_all_time)
         .where("user_stats.posts_read_count >= ?", SiteSetting.tl3_requires_posts_read_all_time)
         .where("user_stats.likes_given >= ?", SiteSetting.tl3_requires_likes_given)
diff --git a/app/models/trust_level3_requirements.rb b/app/models/trust_level3_requirements.rb
index bf511a0..3e35497 100644
--- a/app/models/trust_level3_requirements.rb
+++ b/app/models/trust_level3_requirements.rb
@@ -143,11 +143,7 @@ class TrustLevel3Requirements
   end
 
   def num_topics_replied_to
-    @user.posts
-      .public_posts
-      .where("posts.created_at > ? AND posts.post_number > 1", time_period.days.ago)
-      .select("distinct topic_id")
-      .count
+    @user.user_stat.calc_topic_reply_count!(min_topics_replied_to + 1, time_period.days.ago)
   end
 
   def min_topics_replied_to
diff --git a/app/models/user_stat.rb b/app/models/user_stat.rb
index cda2c15..1eb168b 100644
--- a/app/models/user_stat.rb
+++ b/app/models/user_stat.rb
@@ -4,6 +4,9 @@ class UserStat < ActiveRecord::Base
   belongs_to :user
   after_save :trigger_badges
 
+  # TODO(2021-05-13): Remove
+  self.ignored_columns = ["topic_reply_count"]
+
   def self.ensure_consistency!(last_seen = 1.hour.ago)
     reset_bounce_scores
     update_distinct_badge_count
@@ -151,12 +154,30 @@ class UserStat < ActiveRecord::Base
   end
 
   # topic_reply_count is a count of posts in other users' topics
-  def update_topic_reply_count
-    self.topic_reply_count = Topic
-      .joins("INNER JOIN posts ON topics.id = posts.topic_id AND topics.user_id <> posts.user_id")
-      .where("posts.deleted_at IS NULL AND posts.user_id = ?", self.user_id)
-      .distinct
-      .count
+  def calc_topic_reply_count!(max, start_time = nil)
+    sql = <<~SQL
+    SELECT COUNT(*) count
+    FROM (
+      SELECT DISTINCT posts.topic_id
+      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 > ?'}
+      LIMIT ?
+    ) as user_topic_replies
+    SQL
+    if start_time.nil?
+      DB.query_single(sql, self.user_id, max).first
+    else
+      DB.query_single(sql, self.user_id, start_time, max).first
+    end
+  end
+
+  def any_posts
+    user.posts.exists?
   end
 
   MAX_TIME_READ_DIFF = 100
@@ -212,7 +233,6 @@ end
 #  posts_read_count         :integer          default(0), not null
 #  likes_given              :integer          default(0), not null
 #  likes_received           :integer          default(0), not null
-#  topic_reply_count        :integer          default(0), not null
 #  new_since                :datetime         not null
 #  read_faq                 :datetime
 #  first_post_created_at    :datetime
diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb
index 9e23837..290569a 100644
--- a/app/serializers/current_user_serializer.rb
+++ b/app/serializers/current_user_serializer.rb
@@ -12,8 +12,7 @@ class CurrentUserSerializer < BasicUserSerializer
              :moderator?,
              :staff?,
              :title,
-             :reply_count,
-             :topic_count,
+             :any_posts,
              :enable_quoting,
              :enable_defer,
              :external_links_in_new_tab,
@@ -66,12 +65,8 @@ class CurrentUserSerializer < BasicUserSerializer
     object.user_stat.read_faq?
   end
 
-  def topic_count
-    object.user_stat.topic_count
-  end
-
-  def reply_count
-    object.user_stat.topic_reply_count
+  def any_posts
+    object.user_stat.any_posts
   end
 
   def hide_profile_and_presence
diff --git a/db/post_migrate/20200513185052_drop_topic_reply_count.rb b/db/post_migrate/20200513185052_drop_topic_reply_count.rb
new file mode 100644
index 0000000..5bae343
--- /dev/null
+++ b/db/post_migrate/20200513185052_drop_topic_reply_count.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class DropTopicReplyCount < ActiveRecord::Migration[6.0]
+  DROPPED_COLUMNS ||= {
+    user_stats: %i{
+      topic_reply_count
+    }

[... diff too long, it was truncated ...]

GitHub sha: 869f9b20

This commit appears in #9769 which was approved by ZogStriP. It was merged by riking.