0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/app/models/concerns/has_nested_reply_stats.rb
Mark VanLandingham c85593eda8
PERF: Add fast nested reply stats preparation (#41870)
## Problem

Nested reply statistics currently start backfilling only after nested
replies are enabled. At the default 100 topics every five minutes, that
is 28,800 topics per day—about 174 days for a site with five million
topics. Large sites therefore have to expose the feature before its
historical stats are ready.

## Solution

Add a backend-only `nested_replies:prepare_stats` rake task. For each
site, it:

- enables the existing realtime stats callbacks while the nested-replies
UI remains disabled;
- captures the current regular-topic high-water mark;
- enqueues low-priority, keyset-paginated batches which immediately
enqueue the next batch instead of waiting five minutes;
- reuses the existing per-topic backfill SQL; and
- isolates an individual failed topic into its own job, where normal
Sidekiq retries apply, so later topics can continue.

The task leaves the hidden maintenance flag enabled, so replies created,
deleted, or reparented after preparation begins keep the same stats
current before nested replies are enabled. On multisite it prepares
every database by default; `RAILS_DB` scopes it to one database.

The existing scheduled backfill keeps its original feature gate, topic
selection, batch schedule, and calculations. This adds no UI,
migrations, stats version, readiness marker, durable cursor setting,
lock, or watchdog.

Run it with:

```sh
RAILS_ENV=production bin/rake nested_replies:prepare_stats
```

Completion is logged with the captured high-water topic ID. Re-running
the task is safe because it uses the existing idempotent upsert
behavior.

Tests cover disabled-mode preparation, bounded continuation and
high-water behavior, isolated retries, rake-task enqueueing,
preservation of the old scheduler gate, and realtime maintenance while
the UI is disabled.
2026-07-21 10:45:53 -05:00

176 lines
7.3 KiB
Ruby
Vendored

# frozen_string_literal: true
module HasNestedReplyStats
extend ActiveSupport::Concern
included do
after_create :nested_replies_increment_stats
after_destroy :nested_replies_decrement_stats
end
# Moves this post's subtree from `previous_reply_to_post_number` to its
# current `reply_to_post_number` on the nested stats tree. Shared ancestors
# are left alone — their total descendant count is unchanged — while
# ancestors unique to either chain are adjusted by this post's subtree size.
def nested_replies_apply_reparent(previous_reply_to_post_number)
return unless maintain_nested_reply_stats?
return if previous_reply_to_post_number == reply_to_post_number
subtree_size, whisper_subtree_size = nested_replies_subtree_sizes
is_whisper = post_type == Post.types[:whisper] ? 1 : 0
old_ancestors = nested_replies_walk(previous_reply_to_post_number)
new_ancestors = nested_replies_walk(reply_to_post_number)
old_ids = old_ancestors.map(&:id)
new_ids = new_ancestors.map(&:id)
old_only_ids = old_ids - new_ids
new_only_ids = new_ids - old_ids
old_direct_parent_id = old_ancestors.find { |a| a.depth == 1 }&.id
new_direct_parent_id = new_ancestors.find { |a| a.depth == 1 }&.id
if old_only_ids.any?
DB.exec(<<~SQL, ids: old_only_ids, subtree: subtree_size, whisper: whisper_subtree_size)
UPDATE nested_view_post_stats
SET total_descendant_count = GREATEST(total_descendant_count - :subtree, 0),
whisper_total_descendant_count = GREATEST(whisper_total_descendant_count - :whisper, 0),
updated_at = NOW()
WHERE post_id = ANY(ARRAY[:ids]::int[])
SQL
end
if new_only_ids.any?
DB.exec(<<~SQL, ids: new_only_ids, subtree: subtree_size, whisper: whisper_subtree_size)
INSERT INTO nested_view_post_stats (post_id, direct_reply_count, total_descendant_count,
whisper_direct_reply_count, whisper_total_descendant_count,
created_at, updated_at)
SELECT aid, 0, :subtree, 0, :whisper, NOW(), NOW()
FROM unnest(ARRAY[:ids]::int[]) AS aid
ON CONFLICT (post_id) DO UPDATE SET
total_descendant_count = nested_view_post_stats.total_descendant_count + :subtree,
whisper_total_descendant_count = nested_view_post_stats.whisper_total_descendant_count + :whisper,
updated_at = NOW()
SQL
end
DB.exec(<<~SQL, id: old_direct_parent_id, is_whisper: is_whisper) if old_direct_parent_id
UPDATE nested_view_post_stats
SET direct_reply_count = GREATEST(direct_reply_count - 1, 0),
whisper_direct_reply_count = GREATEST(whisper_direct_reply_count - :is_whisper, 0),
updated_at = NOW()
WHERE post_id = :id
SQL
DB.exec(<<~SQL, id: new_direct_parent_id, is_whisper: is_whisper) if new_direct_parent_id
INSERT INTO nested_view_post_stats (post_id, direct_reply_count, total_descendant_count,
whisper_direct_reply_count, whisper_total_descendant_count,
created_at, updated_at)
VALUES (:id, 1, 0, :is_whisper, 0, NOW(), NOW())
ON CONFLICT (post_id) DO UPDATE SET
direct_reply_count = nested_view_post_stats.direct_reply_count + 1,
whisper_direct_reply_count = nested_view_post_stats.whisper_direct_reply_count + :is_whisper,
updated_at = NOW()
SQL
end
private
def maintain_nested_reply_stats?
SiteSetting.nested_replies_enabled || SiteSetting.nested_replies_stats_maintenance_enabled
end
def nested_replies_increment_stats
return unless maintain_nested_reply_stats?
return if reply_to_post_number.blank?
ancestors = nested_replies_walk(reply_to_post_number)
return if ancestors.empty?
ancestor_ids = ancestors.map(&:id).uniq
direct_parent_id = ancestors.find { |a| a.depth == 1 }&.id
is_whisper = post_type == Post.types[:whisper] ? 1 : 0
DB.exec(<<~SQL, ids: ancestor_ids, parent_id: direct_parent_id, whisper: is_whisper)
INSERT INTO nested_view_post_stats (post_id, direct_reply_count, total_descendant_count,
whisper_direct_reply_count, whisper_total_descendant_count,
created_at, updated_at)
SELECT aid,
CASE WHEN aid = :parent_id THEN 1 ELSE 0 END,
1,
CASE WHEN aid = :parent_id THEN :whisper ELSE 0 END,
:whisper,
NOW(), NOW()
FROM unnest(ARRAY[:ids]::int[]) AS aid
ON CONFLICT (post_id) DO UPDATE SET
total_descendant_count = nested_view_post_stats.total_descendant_count + 1,
direct_reply_count = nested_view_post_stats.direct_reply_count +
CASE WHEN nested_view_post_stats.post_id = :parent_id THEN 1 ELSE 0 END,
whisper_total_descendant_count = nested_view_post_stats.whisper_total_descendant_count + :whisper,
whisper_direct_reply_count = nested_view_post_stats.whisper_direct_reply_count +
CASE WHEN nested_view_post_stats.post_id = :parent_id THEN :whisper ELSE 0 END,
updated_at = NOW()
SQL
end
def nested_replies_decrement_stats
return unless maintain_nested_reply_stats?
if reply_to_post_number.present?
subtree_size, whisper_subtree_size = nested_replies_subtree_sizes
is_whisper = post_type == Post.types[:whisper] ? 1 : 0
ancestors = nested_replies_walk(reply_to_post_number)
if ancestors.present?
ancestor_ids = ancestors.map(&:id)
direct_parent_id = ancestors.find { |a| a.depth == 1 }&.id
DB.exec(
<<~SQL,
UPDATE nested_view_post_stats
SET total_descendant_count = GREATEST(total_descendant_count - :removed, 0),
direct_reply_count = GREATEST(
direct_reply_count - CASE WHEN post_id = :parent_id THEN 1 ELSE 0 END,
0
),
whisper_total_descendant_count = GREATEST(whisper_total_descendant_count - :whisper_removed, 0),
whisper_direct_reply_count = GREATEST(
whisper_direct_reply_count - CASE WHEN post_id = :parent_id THEN :is_whisper ELSE 0 END,
0
),
updated_at = NOW()
WHERE post_id = ANY(ARRAY[:ids]::int[])
SQL
ids: ancestor_ids,
parent_id: direct_parent_id,
removed: subtree_size,
whisper_removed: whisper_subtree_size,
is_whisper: is_whisper,
)
end
end
NestedViewPostStat.where(post_id: id).delete_all
end
def nested_replies_subtree_sizes
stat =
NestedViewPostStat.where(post_id: id).pick(
:total_descendant_count,
:whisper_total_descendant_count,
)
is_whisper = post_type == Post.types[:whisper] ? 1 : 0
[1 + (stat&.first || 0), is_whisper + (stat&.second || 0)]
end
def nested_replies_walk(start_post_number)
return [] if start_post_number.blank?
# Include deleted ancestors — they may still have stat rows from when they
# were alive, and those counts need to stay consistent.
NestedReplies.walk_ancestors(
topic_id: topic_id,
start_post_number: start_post_number,
exclude_deleted: false,
)
end
end