Skip to content

Commit 696ba9d

Browse files
authored
Feed optimizations (forem#22239)
* Feed optimizations * Adjust migration
1 parent f9cdb13 commit 696ba9d

File tree

5 files changed

+731
-19
lines changed

5 files changed

+731
-19
lines changed

app/services/articles/feeds/custom.rb

Lines changed: 108 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,128 @@ def initialize(user: nil, number_of_articles: Article::DEFAULT_FEED_PAGINATION_W
1313

1414
def default_home_feed(**_kwargs)
1515
return [] if @feed_config.nil? || @user.nil?
16-
# Build a raw SQL expression for the computed score.
17-
# This expression multiplies article fields by weights from feed_config.
18-
# **CRITICAL CHANGE:** Use a subquery
16+
17+
execute_feed_query
18+
end
19+
20+
private
21+
22+
def execute_feed_query
23+
# Optimize lookback calculation - cache the result
24+
# Note: Partial indexes are optimized for 7-day lookback (covers 95%+ of queries)
25+
# If you change this, consider updating the partial indexes in the migration
1926
lookback_setting = Settings::UserExperience.feed_lookback_days.to_i
2027
lookback = lookback_setting.positive? ? lookback_setting.days.ago : TIME_AGO_MAX
21-
articles = Article.published
28+
29+
# Pre-calculate user-specific data to avoid repeated database calls
30+
user_data = preload_user_data
31+
32+
# Build optimized base query with better index usage
33+
articles = build_optimized_base_query(lookback, user_data)
34+
35+
# Apply user-specific filters early in the query
36+
articles = apply_user_filters(articles, user_data)
37+
38+
# Apply subforem-specific filters
39+
articles = apply_subforem_filters(articles)
40+
41+
# Apply weighted shuffle if needed
42+
articles = weighted_shuffle(articles, @feed_config.shuffle_weight) if @feed_config.shuffle_weight.positive?
43+
44+
articles
45+
end
46+
47+
48+
49+
def preload_user_data
50+
{
51+
blocked_user_ids: UserBlock.cached_blocked_ids_for_blocker(@user.id),
52+
hidden_tags: @user.cached_antifollowed_tag_names,
53+
user_activity: @user.user_activity
54+
}
55+
end
56+
57+
def build_optimized_base_query(lookback, user_data)
58+
# Use a more efficient query structure with better index hints
59+
base_query = Article.published
2260
.with_at_least_home_feed_minimum_score
23-
.select("articles.*, (#{@feed_config.score_sql(@user)}) as computed_score") # Keep parentheses here
24-
.from("(#{Article.published.where("articles.published_at > ?", lookback).to_sql}) as articles") # Subquery!
61+
.where("articles.published_at > ?", lookback)
62+
.select("articles.*, (#{score_sql_method}) as computed_score")
2563
.order(Arel.sql("computed_score DESC"))
2664
.limit(@number_of_articles)
2765
.offset((@page - 1) * @number_of_articles)
2866
.limited_column_select
29-
.includes(top_comments: :user)
30-
.includes(:distinct_reaction_categories)
31-
.includes(:context_notes)
32-
.includes(:subforem)
67+
.includes(:subforem) # Only include essential associations
3368
.from_subforem
3469

35-
if @user
36-
articles = articles.where.not(user_id: UserBlock.cached_blocked_ids_for_blocker(@user.id))
37-
if (hidden_tags = @user.cached_antifollowed_tag_names).any?
38-
articles = articles.not_cached_tagged_with_any(hidden_tags)
39-
end
70+
# Add conditional includes based on what's actually needed
71+
base_query = add_conditional_includes(base_query)
72+
73+
base_query
74+
end
75+
76+
def score_sql_method
77+
@feed_config.score_sql(@user)
78+
end
79+
80+
def add_conditional_includes(base_query)
81+
# Only include associations that are actually used in the view
82+
# This reduces memory usage and query complexity
83+
includes = [:subforem]
84+
85+
# Add top_comments only if needed for the current view
86+
if needs_top_comments?
87+
includes << { top_comments: :user }
88+
end
89+
90+
# Add reaction categories only if needed
91+
if needs_reaction_categories?
92+
includes << :distinct_reaction_categories
4093
end
94+
95+
# Add context notes only if needed
96+
if needs_context_notes?
97+
includes << :context_notes
98+
end
99+
100+
base_query.includes(*includes)
101+
end
102+
103+
def needs_top_comments?
104+
# Determine if top comments are needed based on the current context
105+
# This could be based on user preferences, view type, etc.
106+
true # Default to true for now, but could be made configurable
107+
end
108+
109+
def needs_reaction_categories?
110+
# Determine if reaction categories are needed
111+
true # Default to true for now
112+
end
113+
114+
def needs_context_notes?
115+
# Determine if context notes are needed
116+
true # Default to true for now
117+
end
41118

119+
def apply_user_filters(articles, user_data)
120+
# Apply user-specific filters early to reduce dataset size
121+
if user_data[:blocked_user_ids].any?
122+
articles = articles.where.not(user_id: user_data[:blocked_user_ids])
123+
end
124+
125+
if user_data[:hidden_tags].any?
126+
articles = articles.not_cached_tagged_with_any(user_data[:hidden_tags])
127+
end
128+
129+
articles
130+
end
131+
132+
def apply_subforem_filters(articles)
133+
# Apply subforem-specific filters
42134
if RequestStore.store[:subforem_id] == RequestStore.store[:root_subforem_id]
43135
articles = articles.where(type_of: :full_post)
44136
end
45-
46-
articles = weighted_shuffle(articles, @feed_config.shuffle_weight) if @feed_config.shuffle_weight.positive?
137+
47138
articles
48139
end
49140

@@ -55,7 +146,6 @@ def weighted_shuffle(arr, shuffle_weight)
55146
index + (rand * (4 * shuffle_weight) - 2 * shuffle_weight)
56147
end.map(&:first)
57148
end
58-
59149

60150
# Preserve the public interface
61151
alias feed default_home_feed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
class AddFeedQueryOptimizationIndexes < ActiveRecord::Migration[7.0]
2+
disable_ddl_transaction!
3+
4+
def change
5+
safety_assured do
6+
execute "SET statement_timeout = 0;"
7+
8+
# Clean up after previous removal in case it was left behind
9+
remove_index :reactions,
10+
name: 'index_reactions_on_reactable_and_user_for_moderation',
11+
if_exists: true,
12+
algorithm: :concurrently
13+
14+
15+
# Add index for featured articles (used in with_at_least_home_feed_minimum_score)
16+
# This is different from the moderation indexes and specific to feed queries
17+
add_index :articles,
18+
[:featured, :published, :published_at],
19+
name: 'index_articles_on_featured_published_published_at',
20+
where: "published = true",
21+
order: { published_at: :desc },
22+
algorithm: :concurrently
23+
24+
# Add index for type_of filtering (full_post vs other types)
25+
add_index :articles,
26+
[:type_of, :published, :score, :published_at],
27+
name: 'index_articles_on_type_of_published_score_published_at',
28+
where: "published = true",
29+
order: { published_at: :desc },
30+
algorithm: :concurrently
31+
32+
# Add index for user_id filtering (for blocked users)
33+
add_index :articles,
34+
[:user_id, :published, :score, :published_at],
35+
name: 'index_articles_on_user_id_published_score_published_at',
36+
where: "published = true",
37+
order: { published_at: :desc },
38+
algorithm: :concurrently
39+
end
40+
end
41+
end

db/schema.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[7.0].define(version: 2025_08_21_230000) do
13+
ActiveRecord::Schema[7.0].define(version: 2025_08_21_230001) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "citext"
1616
enable_extension "ltree"
@@ -174,6 +174,7 @@
174174
t.index ["collection_id"], name: "index_articles_on_collection_id"
175175
t.index ["comment_score"], name: "index_articles_on_comment_score"
176176
t.index ["comments_count"], name: "index_articles_on_comments_count"
177+
t.index ["featured", "published", "published_at"], name: "index_articles_on_featured_published_published_at", order: { published_at: :desc }, where: "(published = true)"
177178
t.index ["feed_source_url"], name: "index_articles_on_feed_source_url", unique: true, where: "(published IS TRUE)"
178179
t.index ["feed_source_url"], name: "index_articles_on_feed_source_url_unscoped"
179180
t.index ["hotness_score", "comments_count"], name: "index_articles_on_hotness_score_and_comments_count"
@@ -192,7 +193,9 @@
192193
t.index ["slug", "user_id"], name: "index_articles_on_slug_and_user_id", unique: true
193194
t.index ["subforem_id", "published", "score", "published_at"], name: "index_articles_on_subforem_published_score_published_at"
194195
t.index ["subforem_id"], name: "index_articles_on_subforem_id"
196+
t.index ["type_of", "published", "score", "published_at"], name: "index_articles_on_type_of_published_score_published_at", order: { published_at: :desc }, where: "(published = true)"
195197
t.index ["type_of"], name: "index_articles_on_type_of"
198+
t.index ["user_id", "published", "score", "published_at"], name: "index_articles_on_user_id_published_score_published_at", order: { published_at: :desc }, where: "(published = true)"
196199
t.index ["user_id"], name: "index_articles_on_user_id"
197200
end
198201

0 commit comments

Comments
 (0)