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:
- 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.
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
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.
By replacing .count
with .async_count
, we completely change the behavior of @most_loved_products
.
- The variable is no longer an
Integer
but aPromise
. - To use its value, you need to call
@most_loved_products.value
. - 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
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
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
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
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
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)
Thanks for sharing!