0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/app/jobs/scheduled/backfill_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

136 lines
5.4 KiB
Ruby
Vendored

# frozen_string_literal: true
module Jobs
class BackfillNestedReplyStats < ::Jobs::Scheduled
every 5.minutes
cluster_concurrency 1
def execute(args = {})
return unless SiteSetting.nested_replies_enabled
args ||= {}
topic_ids = topic_ids_missing_stats(category_id: args[:category_id])
return if topic_ids.empty?
topic_ids.each { |topic_id| self.class.backfill_topic(topic_id) }
end
private
def topic_ids_missing_stats(category_id: nil)
category_id = category_id.to_i
category_filter = category_id.positive? ? "AND t.category_id = :category_id" : ""
DB.query_single(
<<~SQL,
SELECT t.id
FROM topics t
LEFT JOIN nested_topics nt ON nt.topic_id = t.id
INNER JOIN posts op ON op.topic_id = t.id AND op.post_number = 1
LEFT JOIN nested_view_post_stats s ON s.post_id = op.id
WHERE t.deleted_at IS NULL
AND t.archetype = :archetype
AND (:nested_replies_default OR nt.topic_id IS NOT NULL)
#{category_filter}
AND (
s.post_id IS NULL
OR EXISTS (
SELECT 1
FROM posts child
INNER JOIN posts parent
ON parent.topic_id = child.topic_id
AND parent.post_number = child.reply_to_post_number
LEFT JOIN nested_view_post_stats parent_stats
ON parent_stats.post_id = parent.id
WHERE child.topic_id = t.id
AND child.reply_to_post_number IS NOT NULL
AND child.post_number > 1
AND parent_stats.post_id IS NULL
)
)
ORDER BY t.id DESC
LIMIT :batch_size
SQL
archetype: Archetype.default,
batch_size: SiteSetting.nested_replies_backfill_batch_size,
category_id: category_id,
nested_replies_default: SiteSetting.nested_replies_default,
)
end
def self.backfill_topic(topic_id)
DB.exec(<<~SQL, topic_id: topic_id, whisper_type: Post.types[:whisper])
WITH RECURSIVE
edges AS (
SELECT post_number, reply_to_post_number, post_type
FROM posts
WHERE topic_id = :topic_id
AND reply_to_post_number IS NOT NULL
AND post_number > 1
),
direct_counts AS (
SELECT reply_to_post_number AS parent_number, post_type,
COUNT(*) AS cnt
FROM edges
GROUP BY reply_to_post_number, post_type
),
direct_agg AS (
SELECT parent_number,
SUM(cnt) AS direct_reply_count,
SUM(CASE WHEN post_type = :whisper_type THEN cnt ELSE 0 END) AS whisper_direct_reply_count
FROM direct_counts
GROUP BY parent_number
),
ancestor_walk AS (
SELECT e.reply_to_post_number AS ancestor_number,
1 AS descendant_count,
CASE WHEN e.post_type = :whisper_type THEN 1 ELSE 0 END AS whisper_descendant_count,
1 AS depth
FROM edges e
UNION ALL
SELECT p.reply_to_post_number,
a.descendant_count,
a.whisper_descendant_count,
a.depth + 1
FROM ancestor_walk a
JOIN edges p ON p.post_number = a.ancestor_number
WHERE a.depth < 500
),
descendant_agg AS (
SELECT ancestor_number,
COUNT(*) AS total_descendant_count,
SUM(whisper_descendant_count) AS whisper_total_descendant_count
FROM ancestor_walk
GROUP BY ancestor_number
),
combined AS (
SELECT p.id AS post_id,
COALESCE(d.direct_reply_count, 0) AS direct_reply_count,
COALESCE(d.whisper_direct_reply_count, 0) AS whisper_direct_reply_count,
COALESCE(t.total_descendant_count, 0) AS total_descendant_count,
COALESCE(t.whisper_total_descendant_count, 0) AS whisper_total_descendant_count
FROM posts p
LEFT JOIN direct_agg d ON d.parent_number = p.post_number
LEFT JOIN descendant_agg t ON t.ancestor_number = p.post_number
WHERE p.topic_id = :topic_id
AND (p.post_number = 1 OR d.parent_number IS NOT NULL OR t.ancestor_number IS NOT NULL)
)
INSERT INTO nested_view_post_stats
(post_id, direct_reply_count, whisper_direct_reply_count,
total_descendant_count, whisper_total_descendant_count,
created_at, updated_at)
SELECT post_id, direct_reply_count, whisper_direct_reply_count,
total_descendant_count, whisper_total_descendant_count,
NOW(), NOW()
FROM combined
ON CONFLICT (post_id) DO UPDATE SET
direct_reply_count = GREATEST(EXCLUDED.direct_reply_count, nested_view_post_stats.direct_reply_count),
whisper_direct_reply_count = GREATEST(EXCLUDED.whisper_direct_reply_count, nested_view_post_stats.whisper_direct_reply_count),
total_descendant_count = GREATEST(EXCLUDED.total_descendant_count, nested_view_post_stats.total_descendant_count),
whisper_total_descendant_count = GREATEST(EXCLUDED.whisper_total_descendant_count, nested_view_post_stats.whisper_total_descendant_count),
updated_at = NOW()
SQL
end
end
end