Programmatically Defer Sidekiq Jobs

Published: July 31, 2020
Sidekiq will happily chug along and process any and all jobs that are in the queue. But, what if you want it to take a break for a bit?

Why Would You Want This?

Let's say you have a Rails app processing a bunch of jobs of different types - most of them reach out to some 3rd party API. One day, that API breaks for some reason and the dead queue starts filling up. Perhaps you wish to move into some brief pseudo-maintenance period for your app and want jobs to "hold up" until such a period has passed. Or, maybe you really can't deal with Heroku's mandatory dyno once a day restarts and would rather pause jobs, restart the dyno yourself, and then continue on once a day during "off hours."

Here are three options for setting up Sidekiq job deferrals through code.

Option One: Use the Sidekiq API

Sidekiq PRO offers an API to access a given queue to pause and unpause it. The code is rather simple and straightforward...

q = Sidekiq::Queue.new('queue_name')
q.pause!
q.paused? # => true
q.unpause!

This is great... except there are two issues:

  1. This is a Sidekiq PRO feature. While I 100% think Sidekiq PRO is worth the price if just to support Mike Perham's work, purchasing a PRO licence may not be in the cards.
  2. This code applies to an entire queue. Your needs may involve targeting certain job classes to defer rather than everything going into a queue. Granted, you could work around this by putting such classes in their own queue.

Option Two: Sidekiq Middleware

Sidekiq supports adding middleware to both the client and server allowing you to append data to jobs, make decisions, and report errors (this is what the Honeybadger gem uses for its Sidekiq integration). For this option, we'll make a very simple server middleware. Why a server middleware? The Sidekiq server is what processes jobs while the client is what adds jobs to the queues.

Assuming this is a relatively up-to-date Rails app and jobs are using ActiveJob...

# lib/sidekiq_middleware/defer_jobs.rb

class DeferJobs
  def call(worker, job, queue)
    if defer_job?(job)
      Rails.logger.info("Deferring #{job['class']} with arguments (#{job['args'].inspect})")
      job['class'].constantize.set(wait: 3.minutes).perform_later(*job['args'])
      return
    end

    yield
  end

  def defer_job?(job)
    # Your logic to determine if the job should be deferred.
  end
end

When the Sidekiq server pulls a job off of the queue, it will eventually hit the #call method on this file. At this point, the job was pulled off the queue, but has not been executed yet (ie, #perform has not been called on the job class). Three arguments are passed in. We are most interested in the job argument. This hash is the standard "job payload" which Sidekiq works with for pretty much the entire library. The full breakdown of what's in this hash can be found on this wiki page. What we are most interested in are the "class" and "args" keys which is a string of the job class name along with an array of positional arguments to pass into the class's #perform method.

Instead of returning like a normal method, you call yield. This will pass the job arguments to the next middleware until there are no more middleware to run and #perform is called on the job class. Returning early without calling yield will result in #perform never being called - effectively sending the job to /dev/null.

In this case, we are calling #defer_job in our middleware and pass in the job hash. At this point, you can do whatever business logic you wish to determine if the job should be deferred. You could look at a database table for a flag that's set, a redis key, the presence of a file, if the job class starts with the letter "B", the current temperature in Intercourse, Pennsylvania, ... whatever you wish. Once you determine to defer the job, the middleware will take the job class and arguments and effectively re-enqueues it for later. In this case, we use the built in Sidekiq job scheduling to run the job in three minutes. After three minutes, the server will pull the job off and run through this middleware again. Repeat until the job is allowed to run.

Finally, we add the middleware in the Sidekiq server setup file...

# config/initializers/sidekiq.rb

require 'sidekiq_middleware/defer_jobs'

Sidekiq.configure_client do |config|
  # ...
end

Sidekiq.configure_server do |config|
  # ...
  config.server_middleware do |chain|
    chain.add DeferJobs
  end
end

... and we're done. This method is relatively straightforward, allows for fully control over what and when to defer, and integrates nicely into Sidekiq's flow.

One possible downside to this option is it could be a little too out of the way. I don't see custom middlewares in Rails apps very often, and it's possible a senior developer may write something like this and it works great for the longest time, but everyone forgets about it. Later in the app's life, other developers may be perplexed as to how this "deferral logic" works and where it happens since it slips itself so nicely into Sidekiq.

Option Three: Good Old Fashioned Prepended Module

This is a rather "rustic" but simple approach to solving this. Instead of hooking into Sidekiq directly, take control of your own code and stop #perform from doing anything!

# app/jobs/job_deferral.rb

module JobDeferral
  def perform(*args)
    if defer_job?
      Rails.logger.info "Deferring '#{self.class}' (with args: #{args.inspect})"
      self.class.set(wait: 3.minutes).perform_later(*args)
      return
    end

    super
  end

  def defer_job?
    # Logic here...
  end
end


# app/jobs/my_worker.rb

class MyWorker < ApplicationJob
  queue_as :default
  prepend JobDeferral

  def perform(id)
    # Your worker code...
  end
end

This is very similar to the Sidekiq middleware, except we are intercepting the call to #perform via Module.prepend with our own method. The method goes through whatever logic may want to determine deferrals or not and schedules the job for later. Instead of yield, we call super which is the original #perform method of the job class. You may pick and choose which job classes to prepend module instead of every class gaining the extra logic. This allows for any developer to look at any job class code, see a module being prepended, and easily jump to said code to see what it does.

There is one rather annoying side effect. To sidekiq, #perform is called. Deferred or not, Sidekiq sees #perform called and completing without issue. So if you do defer the job, your Rails log will still include the "enqueued job" and "completed job" log entries from Sidekiq. This could lead to confusion while viewing logs to debug issues.

Wrapping It Up

All three options are viable and offer their own pros and cons. I personally prefer the first option where some code can trigger a pause on queues and then unpause them later. The other two options have the benefit of being 100% free and gives you more fine-grained control over logic, but comes with the price of added complexity.

One More Note...

The astute reader may recall seeing a "Quiet" button in the non-pro Sidekiq UI for each worker process. This is NOT the same as pause. Putting a process in "quiet" mode is typically reserved for prepping Sidekiq for restarts. Once you quite a process, you cannot "unquiet" it until Sidekiq is restarted.

Tags: ruby, sidekiq