Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Async Barrier for multiple ActiveRecord queries #265

Open
nguyenhothanhtam0709 opened this issue Jan 5, 2025 · 6 comments
Open

Async Barrier for multiple ActiveRecord queries #265

nguyenhothanhtam0709 opened this issue Jan 5, 2025 · 6 comments

Comments

@nguyenhothanhtam0709
Copy link

I'm learning about using Rails with Falcon web server. I want to wait for multiple ActiveRecord queries (something like await Promise.all of Javascript). Does the below code do the same effect?

def index
    task_query = Task
    if @search.present?
      task_query = task_query.where('title LIKE ?', "%#{@search}%")
    end

    barrier = Async::Barrier.new
    Async do
      barrier.async do
        @tasks = task_query.select(:id, :title, :description, :created_at, :updated_at)
                           .limit(@per_page)
                           .offset(@offset)
      end
      barrier.async do
        @count = task_query.count(:id)
      end

      begin
        barrier.wait
      ensure
        barrier.stop
      end
    end
  end

Please correct me if I'm wrong. Thank you so much.

@ioquatix
Copy link
Member

ioquatix commented Jan 5, 2025

This code mostly looks good to me.

However, you don't need a barrier and can defer the query until rendering by using the following approach:

def index
  task_query = Task
  if @search.present?
    task_query = task_query.where('title LIKE ?', "%#{@search}%")
  end
  
  @tasks = Async do
    task_query.select(:id, :title, :description, :created_at, :updated_at).limit(@per_page).offset(@offset)
  end
  
  @count = Async do
    task_query.count(:id)
  end
end

Then, in the view, use it like this:

  @tasks.wait.each ...
  
  @count.wait ...

This will minimise the contention between the controller and view, as the asynchronous tasks will execute "in the background" until you invoke the synchronisation points #wait in the view. The only downside with this approach is error handling can be a little more tricky - in other words, @count.wait may fail if the query failed. In typical sequential code, this will fail in the controller method, but in the above implementation, the exception will occur in the view during rendering (in theory this is always possible).

A complexity of this approach, is if the view fails for any other reason, the child tasks may not be stopped right away, and may execute to completion, even if they aren't needed. However, falcon will typically stop the request task and any child tasks if the request fails outright, so this is usually cleaned up correctly (cancelled).

Barriers are useful for when you have an indeterminate number of child tasks, e.g. maybe you need to do an HTTP RPC for each record or something like that, the barrier accumulates and helps you manage all those tasks. If you have a specific number of tasks, you can just start them all (e.g. Async{}) and then invoke #wait on those takes when you need the value.

def index
  task_query = Task

  if @search.present?
    task_query = task_query.where('title LIKE ?', "%#{@search}%")
  end

  barrier = Async::Barrier.new

  # Sync block here will enforce all the following code to execute before the view is rendered:
  Sync do
    # Start a task to assign to @tasks:
    barrier.async do
      @tasks = task_query.select(:id, :title, :description, :created_at, :updated_at)
                          .limit(@per_page)
                          .offset(@offset)
    end

    # Start a task to assign to @count:
    barrier.async do
      @count = task_query.count(:id)
    end

    # Wait for those tasks to be completed:
    barrier.wait
  ensure
    # If there is any error (or we exit early for any reason), make sure to stop the barrier (and all tasks):
    barrier.stop
  end
end

The above approach ensures that @tasks and @count are resolved before the controller method finishes, but allows them to resolve concurrently. Either approach is okay, but using a barrier is a little bit more involved.

Hope that helps, let me know if you need more clarification.

@nguyenhothanhtam0709
Copy link
Author

nguyenhothanhtam0709 commented Jan 5, 2025

Thanks for your explanation. Please forgive if I bothered you. I have another question about the config isolation_level = :fiber. Does this config make all ActiveRecord query become non-blocking by running inside a fiber?

@ioquatix
Copy link
Member

ioquatix commented Jan 5, 2025

I have another question about the config isolation_level = :fiber. Does this config make all ActiveRecord query become non-blocking by running inside a fiber?

In principle, that is correct. I also believe it's correct in practice, but only in recent versions of Rails. I personally recommend using Rails 8+.

@nguyenhothanhtam0709
Copy link
Author

I'm currently using Rails 8+. If all queries are non-blocking, I don't need to wrap these queries in Async { ... } block. Is this correct?

@ioquatix
Copy link
Member

ioquatix commented Jan 6, 2025

Queries are non-blocking but still sequential so if you want two queries to happen "at the same time" you will need to use Async{...} blocks.

@nguyenhothanhtam0709
Copy link
Author

nguyenhothanhtam0709 commented Jan 6, 2025

Thank you for taking time to explain it to me. I only want to ensure that all queries (and if possible, other i/o operations) are non-blocking. That's why I'm trying Falcon for Rails app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants