0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/lib/nested_replies/hot_score_cache.rb
Mark VanLandingham dc2d170a9f
FEATURE: Hot algorithm for nested replies (simple) (#41742)
Previously, nested replies offered only `top`, `new`, and `old`
ordering, so active discussions could not surface recently engaged
branches while keeping tree loading bounded.

This change adds default-off `hot` sorting backed by demand-driven
PostgreSQL snapshots and bounded branch preloading, preserves the
resolved fallback across pagination, and skips hot-cache work for small
topics or topics inactive for 30 days.
2026-07-20 10:56:05 -05:00

163 lines
5.7 KiB
Ruby
Vendored

# frozen_string_literal: true
module NestedReplies
module HotScoreCache
Decision = Struct.new(:effective_sort, :mode, :enqueue_result, keyword_init: true)
PURGE_SCORE_BATCH_SIZE = 10_000
PURGE_SNAPSHOT_BATCH_SIZE = 100
MAX_PURGE_STATEMENT_TIMEOUT_MS = 1_000
def self.small_topic_post_limit
SiteSetting.nested_replies_hot_small_topic_post_limit
end
def self.snapshot_ttl
SiteSetting.nested_replies_hot_snapshot_ttl_minutes.minutes
end
def self.max_stale_age
SiteSetting.nested_replies_hot_max_stale_age_days.days
end
def self.active_topic?(topic)
topic.last_posted_at.present? &&
topic.last_posted_at >= SiteSetting.nested_replies_hot_max_inactivity_days.days.ago
end
def self.resolve(topic, requested_sort, requester: nil)
return Decision.new(effective_sort: requested_sort, mode: :not_hot) if requested_sort != "hot"
if !SiteSetting.nested_replies_enabled || !SiteSetting.nested_replies_hot_sort_enabled
return record(Decision.new(effective_sort: "top", mode: :disabled))
end
if topic.deleted_at.present? || !topic.regular? || !topic.nested_view?
return record(Decision.new(effective_sort: "top", mode: :ineligible))
end
if topic.posts_count.to_i <= small_topic_post_limit
return record(Decision.new(effective_sort: "top", mode: :small_topic))
end
unless active_topic?(topic)
return record(Decision.new(effective_sort: "top", mode: :inactive_topic))
end
snapshot = snapshot(topic.id)
return fallback_with_refresh(topic.id, requester, mode: :missing) if snapshot.blank?
calculated_at = snapshot.calculated_at
if calculated_at.blank? || calculated_at < max_stale_age.ago
return fallback_with_refresh(topic.id, requester, mode: :expired)
end
if calculated_at < snapshot_ttl.ago
enqueue_result = request_refresh(topic.id, requester)
return(
record(Decision.new(effective_sort: "hot", mode: :stale, enqueue_result: enqueue_result))
)
end
record(Decision.new(effective_sort: "hot", mode: :fresh))
end
def self.effective_sort(topic, requested_sort, requester: nil)
resolve(topic, requested_sort, requester: requester).effective_sort
end
def self.fresh?(topic)
snapshot = snapshot(topic.id)
snapshot.present? && snapshot.calculated_at.present? &&
snapshot.calculated_at >= snapshot_ttl.ago
end
def self.snapshot(topic_id)
DB.query(<<~SQL, topic_id: topic_id).first
SELECT calculated_at
FROM nested_hot_score_snapshots
WHERE topic_id = :topic_id
SQL
end
def self.purge_expired(cutoff: nil, timeout_ms: MAX_PURGE_STATEMENT_TIMEOUT_MS)
cutoff ||= max_stale_age.ago
timeout_ms = timeout_ms.to_i.clamp(1, MAX_PURGE_STATEMENT_TIMEOUT_MS)
ActiveRecord::Base.transaction do
DB.exec "SET LOCAL statement_timeout = #{timeout_ms}"
DB.exec(
"SET LOCAL lock_timeout = #{[timeout_ms, SiteSetting.nested_replies_hot_lock_timeout_ms].min}",
)
scores_removed = DB.exec(<<~SQL, cutoff: cutoff, batch_size: PURGE_SCORE_BATCH_SIZE)
WITH scores_to_remove AS MATERIALIZED (
SELECT scores.post_id
FROM nested_hot_post_scores scores
LEFT JOIN nested_hot_score_snapshots snapshots
ON snapshots.topic_id = scores.topic_id
WHERE snapshots.topic_id IS NULL
OR snapshots.calculated_at < :cutoff
ORDER BY snapshots.calculated_at NULLS FIRST, scores.post_id
LIMIT :batch_size
)
DELETE FROM nested_hot_post_scores scores
USING scores_to_remove
WHERE scores.post_id = scores_to_remove.post_id
SQL
snapshots_removed = DB.exec(<<~SQL, cutoff: cutoff, batch_size: PURGE_SNAPSHOT_BATCH_SIZE)
WITH snapshots_to_remove AS MATERIALIZED (
SELECT snapshots.topic_id
FROM nested_hot_score_snapshots snapshots
WHERE snapshots.calculated_at < :cutoff
AND NOT EXISTS (
SELECT 1
FROM nested_hot_post_scores scores
WHERE scores.topic_id = snapshots.topic_id
)
ORDER BY snapshots.calculated_at
LIMIT :batch_size
)
DELETE FROM nested_hot_score_snapshots snapshots
USING snapshots_to_remove
WHERE snapshots.topic_id = snapshots_to_remove.topic_id
SQL
{ scores_removed: scores_removed, snapshots_removed: snapshots_removed }
end
end
def self.fallback_with_refresh(topic_id, requester, mode:)
enqueue_result = request_refresh(topic_id, requester)
record(Decision.new(effective_sort: "top", mode: mode, enqueue_result: enqueue_result))
end
private_class_method :fallback_with_refresh
def self.request_refresh(topic_id, requester)
allowed =
RateLimiter.new(
requester,
"nested-hot-score-refresh",
SiteSetting.nested_replies_hot_refresh_requests_per_minute,
1.minute,
apply_limit_to_staff: true,
).performed!(raise_error: false)
return :requester_limited unless allowed
HotScoreQueue.enqueue(topic_id)
rescue Redis::BaseError
:unavailable
end
private_class_method :request_refresh
def self.record(decision)
DiscourseEvent.trigger(
:nested_replies_hot_sort_resolved,
{ mode: decision.mode, enqueue_result: decision.enqueue_result },
continue_on_error: true,
)
decision
end
private_class_method :record
end
end