It’s Time For Active Job

It’s Time For Active Job

Recently we have upgraded one of our own projects to Rails 4.2. New minor version contains many improvements of old features and adds some new ones. One of most interesting (and helpful) new features is adding a new framework for declaring jobs and making them run on a variety of queuing backends — Active Job.

It is hard to imagine any big and complex Rails project without background jobs processing. There are many gems for this task: Delayed Job , Sidekiq , Resque , SuckerPunch and more. And Active Job has arrived here to rule them all.

Active Job provides unified interface to hide real background jobs library from our eyes and to simplify migration from one background jobs processing library to another. Also it is cool that Active Job provides a generator to create jobs. I really don’t like to write almost the same code over and over again and a good tool should take all routine work upon itself.

That looks cool and useful. So it’s time to use Active Job in our project.

Move Delayed Job to Backend

We already use Delayed Job in a project. So, first of all, we should tell Active Job which backend it should use.

We should create initializer for active jobs in the config/initializers folder:

Rails.application.config.active_job.queue_adapter = :delayed_job

We will take as example one of our workers (we used “worker” term for classes which we use in a background):

class SendNewFeedbackNotificationWorker
  attr_reader :showing_id, :user_id

  def initialize(showing_id, user_id)
    @showing_id = showing_id
    @user_id = user_id
  end

  def perform
    showing = Showing.find(showing_id)
    user    = User.find(user_id)
    NewFeedbackNotificationService.new(showing, user).perform
  end
end

And this is the way how we used it:

SendNewFeedbackNotificationWorker.new(showing.id, user.id).delay.perform

Let’s rewrite it for Active Job. We use generator for create new jobs:


$ rails g job SendNewFeedbackNotification

invoke  test_unit
create  test/jobs/send_new_feedback_notification_job_test.rb
create  app/jobs/send_new_feedback_notification_job.rb

We can see that generator creates a test file for us too and this is a good reminder for you to write tests for a job. Before committing changes you will need to choose between deleting a file (we don’t leave empty files in projects) or writing some tests.

Let’s see what a generator has created.

class SendNewFeedbackNotificationJob < ActiveJob::Base
  queue_as :default

  def perform(*args)
    # Do something later
  end
end

It has created a new class which is descendant of ActiveJob::Base with name ending with Job. Also it is set to use a default queue (can be changed by --queue another_queue generator’s option but simple replacing this in code is much quicker).

It is left to change #perform method in the created job:

class SendNewFeedbackNotificationJob < ActiveJob::Base
  queue_as :default

  def perform(showing_id, user_id)
    showing = Showing.find(showing_id)
    user    = User.find(user_id)
    NewFeedbackNotificationService.new(showing, user).perform
  end
end

And replace old calls SendNewFeedbackNotificationWorker.new(arguments).delay.perform to new calls SendNewFeedbackNotificationJob.perform_later(arguments).

Enqueue a job with options

You can change a queue and time for running a job by #set method on job. It can accept these options:

  • :wait - enqueues the job with the specified delay;

  • :wait_until - enqueues the job at the time specified (override :wait if both specified;

  • :queue - enqueues the job on the specified queue.

Examples:

SendNewFeedbackNotificationJob.set(queue: 'urgent').perform_later(showing, user)
SendNewFeedbackNotificationJob.set(wait: 15.minutes).perform_later(showing, user)
SendNewFeedbackNotificationJob.set(wait_until: 10.hours.since(Date.tomorrow)).perform_later(showing, user)

Note: you can check available features for your backend of choice in Rails documentation .

Pass models instances to your jobs

It is a common recommendation to pass models ids instead of models instances to background jobs. Because in this case we already know that model will have the latest saved state when a job will start processing it and this prevents possible issues in serializing and deserializing.

And Global ID was created to simplify jobs. Because using Global ID allows you to pass models to jobs without fear and with less code.

We can rewrite our job using models instead of ids.


class SendNewFeedbackNotificationJob < ActiveJob::Base
  queue_as :default

  def perform(showing, user)
    NewFeedbackNotificationService.new(showing, user).perform
  end
end

And use it in the following way:

SendNewFeedbackNotificationJob.perform_later(showing, user)

All the magic is done by Active Job and Active Record using Global ID.

Active Job serializes arguments which you pass to perform_later. And it has a special case for models which mixin GlobalID::Identification module. In this case Active Job serializer calls .to_global_id.to_s on the model and passes returned string to queue adapter instead of a model.

Before performing job Active Job deserializer detects Global ID identifier, finds a model using GlobalID::Locator.locate and passes the found model as an argument to job’s #perform method.

Testing of Jobs

Active Job also contains a useful module ActiveJob::TestHelper. When we include this module to our test class it overwrites before_setup and after_teardown methods.

In before_setup it sets Active Job to use a test queue adapter instead of the original one and clears lists of enqueued and performed jobs in this adapter. And it restores original adapter in the after_teardown to not interfere on next tests.

Also this module provides several useful methods which allow us to verify that our jobs are working properly.

Let’s assume that we create a job for sending notification to user in the controller:

class UsersFeedbacksController < ApplicationController

  def create
    ...
    SendNewFeedbackNotificationJob.perform_later(showing, user)
  end

end

Then we can use these helpers to check that this action works right.

assert_enqueued_jobs(number) - checks that exact number of jobs were added to queue:

assert_enqueued_jobs 1 do
  post :create
end

assert_enqueued_with(args) - checks that block enqueues job with given arguments:

assert_enqueued_with(job: SendNewFeedbackNotificationJob,
                     args: [showing, user],
                     queue: 'default') do
  post :create
end

perform_enqueued_jobs - performs jobs created in the passed block instead of queuing them:

perform_enqueued_jobs do
  post :create
end

What we have done

So let’s have a look once again on how we have changed the code.

Worker before:

class SendNewFeedbackNotificationWorker
  attr_reader :showing_id, :user_id

  def initialize(showing_id, user_id)
    @showing_id = showing_id
    @user_id = user_id
  end

  def perform
    showing = Showing.find(showing_id)
    user    = User.find(user_id)
    NewFeedbackNotificationService.new(showing, user).perform
  end
end

Job after:

class SendNewFeedbackNotificationJob < ActiveJob::Base
  queue_as :default

  def perform(showing, user)
    NewFeedbackNotificationService.new(showing, user).perform
  end
end

Enqueueing worker before:

SendNewFeedbackNotificationWorker.new(showing.id, user.id).delay.perform

Enqueuing job after:

SendNewFeedbackNotificationJob.perform_later(showing, user)

Looks like our jobs became much cleaner with Active Job.

And a small notice about deploying these changes to production. We should not delete previous SendNewFeedbackNotificationWorker class right now. Because during deploy it may happen that previously used worker was delayed but has not been processed before the deploy. And after deploy this job fails because DelayedJob couldn’t create instance of already deleted class. Adding and deleting of a job shouldn’t be done in single deploy.

Paul Keen is an Open Source Contributor and a Chief Technology Officer at JetThoughts . Follow him on LinkedIn or GitHub .

If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories .