docbrown/app/services/search/postgres/reading_list.rb
rhymes b6e562f14c
[Search 2.0] Optimize search reading list (#13275)
* Use trigger and tsvector column to speed up reading list search

* Add organization destroy spec and todo note

* Fix failing data update script due to not null constraint

* Remove the leading anchor in the trigger regexp

* Fix reading list specs

* Address feedback
2021-04-12 16:40:40 +02:00

132 lines
5.6 KiB
Ruby

module Search
module Postgres
class ReadingList
ATTRIBUTES = [
"articles.cached_tag_list",
"articles.crossposted_at",
"articles.path",
"articles.published_at",
"articles.reading_time",
"articles.title",
"articles.user_id",
"reactions.id AS reaction_id",
"reactions.user_id AS reaction_user_id",
].freeze
REACTION_ATTRIBUTES = %i[id reactable_id user_id].freeze
USER_ATTRIBUTES = %i[id name profile_image username].freeze
DEFAULT_STATUSES = %w[confirmed valid].freeze
DEFAULT_PER_PAGE = 60
MAX_PER_PAGE = 100 # to avoid querying too many items, we set a maximum amount for a page
def self.search_documents(user, term: nil, statuses: [], tags: [], page: 0, per_page: DEFAULT_PER_PAGE)
return {} unless user
statuses = statuses.presence || DEFAULT_STATUSES
tags = tags.presence || []
# NOTE: [@rhymes] we should eventually update the frontend
# to start from page 1
page = page.to_i + 1
per_page = [(per_page || DEFAULT_PER_PAGE).to_i, MAX_PER_PAGE].min
result = find_articles(
user: user,
term: term,
statuses: statuses,
tags: tags,
page: page,
per_page: per_page,
)
# NOTE: [@rhymes] an earlier version used `Article.includes(:user)`
# to preload users, unfortunately it's not possible in Rails to specify
# which fields of the included relation's table to select ahead of time.
# The `users` table is massive (115 columns on March 2021) and thus we
# shouldn't load it all in memory just to select a few fields.
# For these reasons I decided to avoid preloading altogether and issue
# an additional SQL query to load User objects
# (see https://github.com/forem/forem/pull/4744#discussion_r345698674
# and https://github.com/rails/rails/issues/15185#issuecomment-351868335
# for additional context)
user_ids = result[:items].pluck(:user_id)
users = find_users(user_ids)
{
items: serialize(result[:items], users),
total: result[:total]
}
end
def self.find_articles(user:, term:, statuses:, tags:, page:, per_page:)
# [@jgaskins, @rhymes] as `reactions` is potentially a big table, adding pagination
# to an INNER JOIN (eg. `joins(:reactions)`) exponentially decreases the performance,
# incrementing query time as the database has to scan all the rows just to discard
# them right after if they lie outside the bounds of the `OFFSET`.
# Even though it should have had a similar performance, we realized that a subquery
# enabled PostgreSQL query planner to drastically decrease the planned time (ca. 145x)
reaction_query_sql = user.reactions.readinglist
.where(status: statuses, reactable_type: "Article")
.order(created_at: :desc)
.select(*REACTION_ATTRIBUTES)
.to_sql
relation = Article.joins("INNER JOIN (#{reaction_query_sql}) reactions ON reactions.reactable_id = articles.id")
relation = relation.search_reading_list(term) if term.present?
# NOTE: [@rhymes] A previous version was implemented with:
# `.tagged_with(tags, any: false).reselect(*ATTRIBUTES)`
#
# =>`.tagged_with()` merges `articles.*` to the SQL, thus we needed to
# use `reselect()`, see https://github.com/forem/forem/pull/12420
# => `.tagged_with()` with multiple tags constructs a monster query,
# see https://explain.depesz.com/s/CqQV / https://explain.dalibo.com/plan/1Lm
# This is because the `acts-as-taggable-on` query creates a separate INNER JOIN
# per each tag that is added to the list, each new clause uses the `LIKE` operator on `tags.name`.
# That could have been improved by by adding a GIN index on `tags.name`, see
# https://www.cybertec-postgresql.com/en/postgresql-more-performance-for-like-and-ilike-statements/
# and a similar discussion https://github.com/forem/forem/pull/12584#discussion_r570756176
#
# An alternative solution, as we don't need the `Tag` model itself, is to use
# `articles.cached_tag_list` and the `LIKE` operator on it, this could be further
# improved, if needed, by adding a GIN index on `cached_tag_list`
# It seems not to be needed as this approach is roughly 1850 times faster than the previous
# see https://explain.depesz.com/s/ajoP / https://explain.dalibo.com/plan/PZb
tags.each do |tag|
relation = relation.where("articles.cached_tag_list LIKE ?", "%#{tag}%")
end
# here we issue a COUNT(*) after all the conditions are applied,
# because we need to fetch the total number of articles, pre pagination
total = relation.count
relation = relation.select(*ATTRIBUTES)
relation = relation.page(page).per(per_page)
{
items: relation,
total: total
}
end
private_class_method :find_articles
def self.find_users(user_ids)
::User
.where(id: user_ids)
.select(*USER_ATTRIBUTES)
.index_by(&:id)
end
private_class_method :find_users
def self.serialize(articles, users)
Search::ReadingListArticleSerializer
.new(articles, params: { users: users }, is_collection: true)
.serializable_hash[:data]
.pluck(:attributes)
end
private_class_method :serialize
end
end
end