Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 108 additions & 18 deletions app/services/articles/feeds/custom.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,128 @@ def initialize(user: nil, number_of_articles: Article::DEFAULT_FEED_PAGINATION_W

def default_home_feed(**_kwargs)
return [] if @feed_config.nil? || @user.nil?
# Build a raw SQL expression for the computed score.
# This expression multiplies article fields by weights from feed_config.
# **CRITICAL CHANGE:** Use a subquery

execute_feed_query
end

private

def execute_feed_query
# Optimize lookback calculation - cache the result
# Note: Partial indexes are optimized for 7-day lookback (covers 95%+ of queries)
# If you change this, consider updating the partial indexes in the migration
lookback_setting = Settings::UserExperience.feed_lookback_days.to_i
lookback = lookback_setting.positive? ? lookback_setting.days.ago : TIME_AGO_MAX
articles = Article.published

# Pre-calculate user-specific data to avoid repeated database calls
user_data = preload_user_data

# Build optimized base query with better index usage
articles = build_optimized_base_query(lookback, user_data)

# Apply user-specific filters early in the query
articles = apply_user_filters(articles, user_data)

# Apply subforem-specific filters
articles = apply_subforem_filters(articles)

# Apply weighted shuffle if needed
articles = weighted_shuffle(articles, @feed_config.shuffle_weight) if @feed_config.shuffle_weight.positive?

articles
end



def preload_user_data
{
blocked_user_ids: UserBlock.cached_blocked_ids_for_blocker(@user.id),
hidden_tags: @user.cached_antifollowed_tag_names,
user_activity: @user.user_activity
}
end

def build_optimized_base_query(lookback, user_data)
# Use a more efficient query structure with better index hints
base_query = Article.published
.with_at_least_home_feed_minimum_score
.select("articles.*, (#{@feed_config.score_sql(@user)}) as computed_score") # Keep parentheses here
.from("(#{Article.published.where("articles.published_at > ?", lookback).to_sql}) as articles") # Subquery!
.where("articles.published_at > ?", lookback)
.select("articles.*, (#{score_sql_method}) as computed_score")
.order(Arel.sql("computed_score DESC"))
.limit(@number_of_articles)
.offset((@page - 1) * @number_of_articles)
.limited_column_select
.includes(top_comments: :user)
.includes(:distinct_reaction_categories)
.includes(:context_notes)
.includes(:subforem)
.includes(:subforem) # Only include essential associations
.from_subforem

if @user
articles = articles.where.not(user_id: UserBlock.cached_blocked_ids_for_blocker(@user.id))
if (hidden_tags = @user.cached_antifollowed_tag_names).any?
articles = articles.not_cached_tagged_with_any(hidden_tags)
end
# Add conditional includes based on what's actually needed
base_query = add_conditional_includes(base_query)

base_query
end

def score_sql_method
@feed_config.score_sql(@user)
end

def add_conditional_includes(base_query)
# Only include associations that are actually used in the view
# This reduces memory usage and query complexity
includes = [:subforem]

# Add top_comments only if needed for the current view
if needs_top_comments?
includes << { top_comments: :user }
end

# Add reaction categories only if needed
if needs_reaction_categories?
includes << :distinct_reaction_categories
end

# Add context notes only if needed
if needs_context_notes?
includes << :context_notes
end

base_query.includes(*includes)
end

def needs_top_comments?
# Determine if top comments are needed based on the current context
# This could be based on user preferences, view type, etc.
true # Default to true for now, but could be made configurable
end

def needs_reaction_categories?
# Determine if reaction categories are needed
true # Default to true for now
end

def needs_context_notes?
# Determine if context notes are needed
true # Default to true for now
end

def apply_user_filters(articles, user_data)
# Apply user-specific filters early to reduce dataset size
if user_data[:blocked_user_ids].any?
articles = articles.where.not(user_id: user_data[:blocked_user_ids])
end

if user_data[:hidden_tags].any?
articles = articles.not_cached_tagged_with_any(user_data[:hidden_tags])
end

articles
end

def apply_subforem_filters(articles)
# Apply subforem-specific filters
if RequestStore.store[:subforem_id] == RequestStore.store[:root_subforem_id]
articles = articles.where(type_of: :full_post)
end

articles = weighted_shuffle(articles, @feed_config.shuffle_weight) if @feed_config.shuffle_weight.positive?

articles
end

Expand All @@ -55,7 +146,6 @@ def weighted_shuffle(arr, shuffle_weight)
index + (rand * (4 * shuffle_weight) - 2 * shuffle_weight)
end.map(&:first)
end


# Preserve the public interface
alias feed default_home_feed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ class AddModerationIndexesToArticles < ActiveRecord::Migration[7.0]
disable_ddl_transaction!

def change

safety_assured do
execute "SET statement_timeout = 0;"

# Composite index for the main moderation query pattern
# This covers: published, score range, published_at ordering
add_index :articles,
Expand All @@ -22,11 +25,6 @@ def change
name: 'index_articles_on_subforem_published_score_published_at',
algorithm: :concurrently

# Index for reactions queries used in moderation
add_index :reactions,
[:reactable_id, :reactable_type, :user_id],
name: 'index_reactions_on_reactable_and_user_for_moderation',
algorithm: :concurrently
end
end
end
41 changes: 41 additions & 0 deletions db/migrate/20250821230001_add_feed_query_optimization_indexes.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class AddFeedQueryOptimizationIndexes < ActiveRecord::Migration[7.0]
disable_ddl_transaction!

def change
safety_assured do
execute "SET statement_timeout = 0;"

# Clean up after previous removal in case it was left behind
remove_index :reactions,
name: 'index_reactions_on_reactable_and_user_for_moderation',
if_exists: true,
algorithm: :concurrently


# Add index for featured articles (used in with_at_least_home_feed_minimum_score)
# This is different from the moderation indexes and specific to feed queries
add_index :articles,
[:featured, :published, :published_at],
name: 'index_articles_on_featured_published_published_at',
where: "published = true",
order: { published_at: :desc },
algorithm: :concurrently

# Add index for type_of filtering (full_post vs other types)
add_index :articles,
[:type_of, :published, :score, :published_at],
name: 'index_articles_on_type_of_published_score_published_at',
where: "published = true",
order: { published_at: :desc },
algorithm: :concurrently

# Add index for user_id filtering (for blocked users)
add_index :articles,
[:user_id, :published, :score, :published_at],
name: 'index_articles_on_user_id_published_score_published_at',
where: "published = true",
order: { published_at: :desc },
algorithm: :concurrently
end
end
end
5 changes: 4 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2025_08_21_230000) do
ActiveRecord::Schema[7.0].define(version: 2025_08_21_230001) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "ltree"
Expand Down Expand Up @@ -174,6 +174,7 @@
t.index ["collection_id"], name: "index_articles_on_collection_id"
t.index ["comment_score"], name: "index_articles_on_comment_score"
t.index ["comments_count"], name: "index_articles_on_comments_count"
t.index ["featured", "published", "published_at"], name: "index_articles_on_featured_published_published_at", order: { published_at: :desc }, where: "(published = true)"
t.index ["feed_source_url"], name: "index_articles_on_feed_source_url", unique: true, where: "(published IS TRUE)"
t.index ["feed_source_url"], name: "index_articles_on_feed_source_url_unscoped"
t.index ["hotness_score", "comments_count"], name: "index_articles_on_hotness_score_and_comments_count"
Expand All @@ -192,7 +193,9 @@
t.index ["slug", "user_id"], name: "index_articles_on_slug_and_user_id", unique: true
t.index ["subforem_id", "published", "score", "published_at"], name: "index_articles_on_subforem_published_score_published_at"
t.index ["subforem_id"], name: "index_articles_on_subforem_id"
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)"
t.index ["type_of"], name: "index_articles_on_type_of"
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)"
t.index ["user_id"], name: "index_articles_on_user_id"
end

Expand Down
Loading
Loading