Batches

Mike Perham edited this page Nov 16, 2016 · 60 revisions

Batches are Sidekiq Pro's term for a collection of jobs which can be monitored as a group. You can create a set of jobs to execute in parallel and then execute a callback when all the jobs are finished.

See batches in action here:

Batches

Overview

Some businesses upload a lot of Excel spreadsheets to load data into their database. These spreadsheets might have hundreds of rows, each row requiring a few seconds of processing. I don't want to process the file synchronously (the web browser will time out after 60 seconds) and I don't want to spin off the upload as a single Sidekiq job (there's no performance benefit to serial execution). Instead I want to break up the Excel spreadsheet into one job per row and get the benefit of parallelism to massively speed up the data load time. But how do I know when the entire thing is done? How do I track the progress?

This is what batches allow you to do!

batch = Sidekiq::Batch.new
batch.description = "Batch description (this is optional)"
batch.on(:success, MyCallback, :to => user.email)
batch.jobs do
  rows.each { |row| RowWorker.perform_async(row) }
end
puts "Just started Batch #{batch.bid}"

Here we've created a new Batch, told it to fire a callback when all jobs are successful and then filled it with jobs to perform. The bid, or Batch ID, is the unique identifier for a Batch.

You can dynamically add jobs to a batch from within an executing job:

class SomeWorker
  include Sidekiq::Worker
  def perform(...)
    puts "Working within batch #{bid}"
    batch.jobs do
      # add more jobs
    end
  end
end

bid is a method on Sidekiq::Worker which gives access to the ID of the Batch associated with this job. batch is a method on Sidekiq::Worker that gives access to the Batch associated to this job.

The jobs method is atomic. All jobs created in the block are actually pushed atomically at the end of the block. If an error is raised, none of the jobs will go to Redis.

Status

To fetch the status for a Batch programmatically, you use Sidekiq::Batch::Status:

status = Sidekiq::Batch::Status.new(bid)
status.total # jobs in the batch => 98
status.failures # failed jobs so far => 5
status.pending # jobs which have not succeeded yet => 17
status.created_at # => 2012-09-04 21:15:05 -0700
status.complete? # if all jobs have executed at least once => false
status.join # blocks until the batch is considered complete, note that some jobs might have failed
status.failure_info # an array of failed jobs
status.data # a hash of data about the batch which can easily be converted to JSON for javascript usage

Callbacks

Sidekiq can notify you when a Batch is complete or successful with batch.on(event, klass, options={}):

  1. success - when all jobs in the batch have completed successfully.
  2. complete - when all jobs in the batch have run once, successful or not.
class SomeClass
  def on_complete(status, options)
    puts "Uh oh, batch has failures" if status.failures != 0
  end
  def on_success(status, options)
    puts "#{options['uid']}'s batch succeeded.  Kudos!"
  end
end
batch = Sidekiq::Batch.new
batch.on(:success, SomeClass, 'uid' => current_user.id)
# You can also use Class#method notation
batch.on(:complete, 'AnotherClass#method', 'uid' => current_user.id)

Regarding success, if a job fails continually it's possible the success event will never fire.

If you create a batch which has no jobs, the callbacks will never fire because no jobs were executed. Your code should handle that empty case where necessary.

Expiration

Normally batches complete quickly and are removed from Redis upon success. Pending batches expire in Redis after 30 days. Callbacks won't trigger and you will have to deal with performing any cleanup work manually.

Manually deleting a job for a batch

Say you have a batch b with three jobs, j1, j2 and j3. Suppose b has a success callback. j1 and j2 complete successfully but j3 fails for some reason. If you delete the job j3 manually, then the batch callback will never automatically complete. (It is recommended that you allow jobs to intelligently cancel themselves.)

In this case, the batch data will remain in Redis until it expires. The Web UI will show the batch as waiting on a job that has now been deleted.

Callback Details

Batch callbacks run in their own job. If there are errors in the batch callback, it will retry like any other job. You can specify a different queue for the callback jobs so they have a higher priority:

batch = Sidekiq::Batch.new
batch.callback_queue = 'critical'
batch.on(:success, ...)
batch.jobs ...

Monitoring

Sidekiq Pro contains extensions for the Sidekiq Web UI, including an overview for Batches which shows the current status of all Batches along with a Batch details page listing any errors associated with jobs in the Batch. Require the Pro extension where you require the standard Web UI:

require 'sidekiq/pro/web'
mount Sidekiq::Web => '/sidekiq'

Note that the UI shows all in-progress batches. Successful batches are removed so as to not fill up the UI.

Polling

You can poll for the status of a batch (perhaps to show a progress bar as the batch is processed) using the built-in Rack endpoint. Add it to your application's config.ru:

require 'sidekiq/rack/batch_status'
use Sidekiq::Rack::BatchStatus
run Myapp::Application

Then you can query the server to get a JSON blob of data about a batch by passing the BID. For example:

http://localhost:3000/batch_status/bc7f822afbb40747.json

{"complete":true,"bid":"bc7f822afbb40747","total":10,"pending":0,"description":null,"failures":0,"created_at":1367700200.1111438,"fail_info":[]}

Canceling a Batch

If a batch of jobs is no longer valid, can you cancel them or remove them from Redis?

Sidekiq's internal data structures don't make it efficient to remove a job in Redis. Instead I recommend you have each job check if it is still valid when it executes. This way the jobs don't do any extra work and Redis is happy. The Batch API makes this pretty easy to do.

Step 1 Create the batch as normal:

batch = Sidekiq::Batch.new
batch.jobs do
  # define your work
end
# save batch.bid somewhere

Step 2 Cancel the batch due to some user action

batch = Sidekiq::Batch.new(bid)
batch.invalidate_all

Step 3 Each job verifies its own validity

class MyWorker
  include Sidekiq::Worker

  def perform
    return unless valid_within_batch? # this method is on Sidekiq::Worker
    # do actual work
  end
end

API

You can iterate through all known Batches, getting a Sidekiq::Batch::Status for each entry:

bs = Sidekiq::BatchSet.new
bs.each do |status|
  puts status.bid
end

Sidekiq::BatchSet will contain only Batches with outstanding jobs.

In some rare cases, you no longer need a batch in Redis. To remove batch data from Redis, use delete:

batch = Sidekiq::Batch::Status.new(bid) # bid is the batch ID
batch.delete

Deleting a batch will break Sidekiq if there are still jobs associated with that batch in Redis.

Pub/Sub

The result of every batch and batch job is sent to the batch-#{bid} channel in Redis. If you want to follow the progress of a batch in real-time, your code can subscribe to that channel and update the user. Sidekiq sends the following tokens:

  • + - a job succeeded
  • - - a job failed (and might be retried)
  • ! - the batch is considered complete now, all jobs have executed
  • $ - the batch has succeeded, all jobs executed successfully
# NB: this is a blocking, infinite loop.
Sidekiq.redis do |conn|
  conn.psubscribe("batch-*") do |on|
    on.pmessage do |pattern, channel, msg|
      # channel = 'batch-123456789'
      # msg = '-', '+', '$' or '!'
      if msg == "$"
        conn.punsubscribe
        # a batch has succeeded, do something with it.
        bid = channel.match(/batch-(.+)/)[1]
        finalize_batch(bid)
      end
    end
  end
end