DEV Community

Cover image for Turbocharge Your Rails App: A Comprehensive Guide to Async Queries
Pimp My Ruby
Pimp My Ruby

Posted on

Turbocharge Your Rails App: A Comprehensive Guide to Async Queries

With the release of Rails 7.1, an intriguing feature has emerged: the implementation of Async Queries. This functionality opens new horizons for Ruby on Rails developers, providing an innovative approach to optimize controller performance.

Let's explore how async queries work, how to implement them, and what they truly bring, using a practical use case: optimizing an e-commerce dashboard.

Dataset Presentation

As usual, we'll take a practical use case to illustrate all the examples in this article. Today, we'll use a simplified e-commerce scenario. Here's the basic database schema:

Image description

  • A User has many Baskets.
  • A Basket has many Products through BasketsProduct.

Sufficient material to play around with some complex queries!

But before diving into our example, let's understand what async queries are fundamentally.


What Are Async Queries?

In one sentence: Async Query allows you to prepare your database queries in the background before reaching the moment of exploiting that data.

Take a simple example: I want to get the most purchased items on my site. Let's say, in the Product model, I have a scope order_by_baskets that allows me to order products by the number of times they appear in the BasketsProduct table.

Without Active Query, it would look like this:



# In your controller:
@most_loved_products = Product.order_by_baskets.count
sleep 1
# We add sleep to simulate other actions in the controller.


Enter fullscreen mode Exit fullscreen mode

You can then use the value of @most_loved_products by calling it as is. The server-side result is as follows:



Product Load (366.0ms)
Completed 200 OK in 1423ms


Enter fullscreen mode Exit fullscreen mode

The SQL query in @most_loved_products is executed only when needed, i.e., in the view. That's why we have sequentially the one-second sleep and the data loading.

Now, let's add some asynchronous processing! Our controller becomes:



# In your controller:
@most_loved_products = Product.group_by_baskets.async_count
sleep 1
# We add sleep to simulate other actions in the controller.


Enter fullscreen mode Exit fullscreen mode

By replacing .count with .async_count, we completely change the behavior of @most_loved_products.

  1. The variable is no longer an Integer but a Promise.
  2. To use its value, you need to call @most_loved_products.value.
  3. And most importantly, the return value is calculated in the background.

Looking at the server side, we get this:



ASYNC Product Count (0.0ms) (db time 404.5ms)
Completed 200 OK in 1021ms


Enter fullscreen mode Exit fullscreen mode

The line logging the Product count reveals everything. In the main thread, it took 0ms of bandwidth because the counting is asynchronous, as indicated by the keyword ASYNC at the beginning of the line. But in reality, the count took ~400ms of "db time." The DB Time is this action in the form of a Promise that is in the background.

When the thread is blocked by sleep, it does not stop the async count from doing its job on its side.

This is where asynchronous actions in Rails are very powerful!


Different Types of Asynchronous Actions

In the ActiveRecord::Calculations module, you can find several asynchronous methods that are exposed. Here's an example of a controller that uses them all:



def index
    # We want to know the average basket total
    @average_basket_price = Basket.async_average(:total)
    # We want to know the maximum basket total
    @maximum_basket_price = Basket.async_maximum(:total)
    # We want to know the minimum basket total
    @minimum_basket_price = Basket.async_minimum(:total)
    # We want to know the last user's email
    @last_user_email = User.order(:created_at).async_pick(:email)
    # We want to know the revenue this month
    @revenue_this_month = Basket.where(created_at: DateTime.now.all_month).async_sum(:total)
    # We want to know more about the most loved products
    most_loved_products = Product.order_by_baskets.limit(100)
    # We want to pluck their name
    @most_loved_products = most_loved_products.async_pluck(:name)
    # We want to know how many times they were bought
    @most_loved_products_count = most_loved_products.async_count
  end



Enter fullscreen mode Exit fullscreen mode

Let's compare the "synchronous" and "asynchronous" versions of the two calls from our server:

Synchronous:



Basket Average (28.1ms)
Basket Maximum (9.7ms)
Basket Minimum (11.4ms)
User Pluck (15.1ms)
Basket Sum (24.9ms)
Product Pluck (413.2ms)
Product Count (294.3ms)
Completed 200 OK in 817ms


Enter fullscreen mode Exit fullscreen mode

Asynchronous:



ASYNC Basket Average (47.0ms) (db time 48.4ms)
ASYNC Basket Maximum (0.0ms) (db time 45.1ms)
ASYNC Basket Minimum (0.0ms) (db time 42.5ms)
ASYNC User Pluck (0.0ms) (db time 41.6ms)
ASYNC Basket Sum (1.7ms) (db time 15.6ms)
ASYNC Product Pluck (344.8ms) (db time 358.5ms)
ASYNC Product Count (0.0ms) (db time 324.3ms)
Completed 200 OK in 410ms


Enter fullscreen mode Exit fullscreen mode

The Asynchronous version is about 2 times faster than the Synchronous version while performing exactly the same processing!


To Use or Not to Use Async Queries?

At this point, you might be thinking, "Great, I can finally optimize my dashboard that takes 2 seconds to load 🎉🎉." It's not that simple.

In fact, when you make an Async Query, you open a new connection to your database. But remember that you have a limited number of connections!

Suppose you have a dashboard that sends 10 very complex queries, and you have 10 admins loading the page simultaneously. This will require 100 available slots in your thread pool allocated to your database. This can quickly lead to thread pool saturation errors!

Another thing, when you surround your query with a Transaction, it won't work with an Async Query:



Basket.transaction do
  @maximum = Basket.async_average(:total)
end
# Will raise an ActiveRecord::AsynchronousQueryInsideTransactionError


Enter fullscreen mode Exit fullscreen mode

Keeping all this in mind, let me try to summarize my recommendations regarding the use of Async Queries.

My Recommendations

Here is a set of questions to ask yourself before converting a query to an async query :

  • Is my query short (-50ms)?
    • YES → No interest.
    • NO → You can consider it.
  • Is my query called very frequently?
    • YES → It's dangerous because it multiplies your number of database accesses. Since this access is limited, it can cause problems.
    • NO → You can consider it.
  • Does my query use a transaction?
    • YES → In this case, it's impossible.
    • NO → You can consider it.

Conclusion

In conclusion, the Async Queries introduced with Rails 7.1 represent a significant advancement in performance optimization for Ruby on Rails developers. Beyond simple improvements, they revolutionize our approach to queries by enabling effective data preparation in the background.

However, it is imperative to adopt these asynchronous queries cautiously, carefully assessing the nature of each query, its frequency of use, and its interaction with transactions, to fully leverage this innovation without compromising system resources.

In summary, Async Queries offer revolutionary potential for Rails application efficiency, but their use must be judicious to ensure an optimal user experience.

Top comments (1)

Collapse
 
iamak profile image
Ajithkumar P S

Thanks for sharing!