Preloading data and dealing with N+1 Query problems

When working with the Discourse rails application, whether building a plugin, or making a pull request to discourse/discourse there are a number of contexts where you’ll encounter N+1 query problems. This topic explains how to handle data preloading to address such problems and keep Discourse performant.

N+1 Query Problems in Rails

First, if you’re not already familiar with N+1 query problems, and how Rails addresses them, check out this section of the guides.

Topics

The N+1 issues to look out for when loading an topic are when you load tables associated with a post. Now, data serialized for an individual topic is prepared, and preloaded, in the TopicView class. That class has an on_preload hook which allows you to preload associated tables before it runs its primary query. There’s a good example of a use of TopicView.on_preload in the ActivityPub Plugin

This use of the hook is both preloading custom fields, which we’ll cover below, and loading additional associations for the posts in the topic. You’ll note that it’s using ActiveRecord::Associations::Preloader to do that. You can read about that class in the docs:

https://www.rubydoc.info/docs/rails/ActiveRecord/Associations/Preloader

Topic Lists

The N+1 issues to look out for when loading a topic list are when loading tables associated with a topic. Similar to the TopicView class for individual topics, there’s a TopicList class for topic lists. And just like TopicView, TopicList also has an on_preload method you can use to hook into the main topic list query to load associated tables before the query hits the database. There’s a good example of that in the Discourse Assign Plugin:

register_topic_preloader_associations

The TopicList class has a Server Plugin API method register_topic_preloader_associations, which essentially applies the same pattern we saw in the ActivityPub Plugin, using ``ActiveRecord::Associations::Preloader`. It will do that work for you if you pass it an array of associations.

register_topic_list_preload_user_ids

In addition to running one big query to get all the topics needed, the TopicList class also runs one big query to get all the users needed for the list, including each:

  • topic user
  • last post user
  • topic featured user
  • topic allowed users

If you’re loading other users for each topic, this api method lets you include their user ids in the big user query so their data will be preloaded along with the rest.

Categories

There’s a main list of categories used in various places in the site that is loaded, and cached, in the Site model. While this is technically not preloading in the strict sense, it’s doing a similar thing, so I thought I’d include it here.

site_all_categories_cache_query modifier

The all_categories_cache method has a Server Plugin API modifier which lets you modify the big categories query: site_all_categories_cache_query. You use this modifier like so in your plugin.rb:

register_modifier(:site_all_categories_cache_query) do |query|
   query.where("categories.name LIKE 'Cool%'")
end

Search

Search preloading is also not technically described as such in the code, but it’s also doing a similar thing with its queries, i.e. eager loading.

register_search_topic_eager_load

The Server Plugin API method register_search_topic_eager_load allows you to eager load additional tables in Search. For example:

register_search_topic_eager_load do |opts|
    %i(example_table)
end

Custom Fields

Custom Fields are powered by the HasCustomFields concern. That concern is included in the models that have associated custom fields, i.e. Post, Topic, Category and Group. The HasCustomFields module has its own preloading system which can be used in various ways. The best way of using HasCustomFields preloading is via the Server Plugin API methods, which should be relatively self explanatory given everything I’ve said so far.

register_preloaded_category_custom_fields

add_preloaded_group_custom_field

add_preloaded_topic_list_custom_field

You can also use the some of the lower level methods in HasCustomFields to do your own preloading when necessary, like we saw in the ActivityPub Plugin example. In that example HasCustomFields concern’s preload_custom_fields is used to preload post custom fields in TopicView.on_preload.

TopicView.on_preload do |topic_view|
  ##
  Post.preload_custom_fields(topic_view.posts, activity_pub_post_custom_field_names)
  ##
end
3 Likes