Enqueue multiple jobs together with perform_all_later in Rails

Enqueue Multiple Jobs Together with perform_all_later in Rails

Are you still looping over the list of jobs to enqueue them individually? This post explores the new `perform_all_later` method in Rails, which was introduced in Rails 7.1 and lets you enqueue multiple jobs together to reduce the round-trip to the job backend, like Redis or the database.

4 min read

Sometimes, you have a large number of jobs to dispatch. Maybe you're sending hundreds of thousands of emails at once, or processing a large file containing thousands of records and dispatching a job for each.

Agreed, you're already using a background job, so your end users don't have to wait for the whole job to finish. However, enqueuing each job still takes some work, right? You loop over each job and enqueue it individually. That means your application has to talk to the job backend (database, Redis, etc.) once for each job.

Wouldn't it be nice if there was a method that could let you enqueue all the jobs together, all in one go, without the overhead of multiple round-trips to the queue datastore?

Turns out, there is!

Rails 7.1 shipped with a handy perform_all_later method that lets you enqueue multiple jobs to be executed at once.

At this time, as far as I know, it's only properly supported by Sidekiq and GoodJob.

  • Sidekiq's push_bulk method cuts down on Redis round-trip latency by pushing multiple jobs with Redis' lpush command.
  • GoodJob's bulk-enqueue feature buffers and enqueues multiple jobs at once using a single INSERT statement on the PostgreSQL database.

For other backends like Delayed Job and Resque it still loops over the jobs.

However, the nice thing about using perform_all_later is, as other job backends implement this functionality (here is Ben Sheldon, creator of GoogJob trying to implement it in recently released Solid Queue ), your code won't have to change if you're using this method.

Note: This method won't trigger any callbacks for each job. However, it fires a enqueue_all.active_job event which you can subscribe to and get the adapter name and the list of jobs to be enqueued.

To learn more about the instrumentation API in Rails, check out the following article.

Understanding the Instrumentation API in Rails
The Instrumentation API in ActiveSupport serves a dual purpose. You can use it to implement the observer (pub-sub) pattern, as well as benchmark how long it took to execute some action. In this post, we’ll learn almost everything you need to know about the Rails Instrumentation API.

Queue adapters can communicate the enqueue status of each job by setting successfully_enqueued and/or enqueue_error on the passed-in job instances.

Since you have access to the job classes in the event subscriber payload, you can query which jobs were enqueued successfully using the successfully_enqueued? method on the job.

enqueued_jobs = jobs.select(&:successfully_enqueued?)

Sidekiq has already supported this functionality for a while now, first with Sidekiq::Client.push_bulk method and later with a high-level wrapper method called perform_bulk, which you can directly call on your job class.

How It Works

The perform_all_later method accepts an array of job instances. You can use it as follows:

reminder_jobs = users.map do |user|
  ReminderJob.new(user).set(wait: 1.day)
end

ActiveJob.perform_all_later(reminder_jobs)

Here's a highly simplified implementation of the perform_all_later method.

# lib/active_job/enqueuing.rb

def perform_all_later(*jobs)
  if queue_adapter.respond_to?(:enqueue_all)
    queue_adapter.enqueue_all(jobs)
  else
    jobs.each do |job|
      queue_adapter.enqueue(job)
    end        
  end
end

As you can see, it first checks if the queue adapter, i.e. Resque, Sidekiq, etc. responds to the enqueue_all method. If yes, then it calls enqueue_all passing the list of jobs. Otherwise, it simply loops over each job and enqueues it individually.

Now let's see what a sample enqueue_all implementation does. We'll look at the Sidekiq adapter.

# lib/active_job/queue_adapters/sidekiq_adapter.rb

def def enqueue_all(jobs)
  Sidekiq::Client.push_bulk(
    "class" => JobWrapper,
    "wrapped" => job_class,
    "queue" => queue,
    "args" => jobs.map { |job| [job.serialize] },
  )
end

So behind the scenes, the enqueue_all uses the push_bulk feature in Sidekiq to dispatch all the jobs at once.

I couldn't stop myself from going one step further and taking a peek at the Sidekiq's implementation of the push_bulk method, which is quite interesting. But that's a topic for a separate post. So stay tuned!

P.S. If you'd like to learn more about how Active Job is implemented, check out the following post 👇

Rails Internals: A Deep Dive Into Active Job Codebase
Do you want to understand how Active Job works behind the scenes? Reading and understanding the source code is one of the best ways to learn a framework (or anything). This post breaks it all down for you by tracing the journey of a Rails background job, from its creation to execution.

That's a wrap. I hope you found this article helpful and you learned something new.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.