© Brady Somerville, Adam Gamble, Cloves Carneiro Jr and Rida Al Barazi 2020
B. Somerville et al.Beginning Rails 6https://doi.org/10.1007/978-1-4842-5716-6_13

13. Active Job

Brady Somerville1 , Adam Gamble2, Cloves Carneiro Jr.3 and Rida Al Barazi4
(1)
Bowling Green, KY, USA
(2)
Gardendale, AL, USA
(3)
Hollywood, FL, USA
(4)
FONTHILL, ON, Canada
 

Web applications often need to perform long-running tasks in response to a request. For example, in the previous chapter, we modified our blog to send emails. While it’s true that sending an email usually only takes a second or so, web developers are often concerned with milliseconds. So what’s the big deal with some requests taking a second or two?

For illustration, imagine going to your local Post Office to drop off a package for delivery. There are a few employees at the counter accepting packages for delivery and a long line of customers waiting to be helped. When it’s finally your turn to be helped at the counter, you hand the package to the employee, and they say, “Thank you! Please wait here at the counter with me until we have delivered your package.” No wonder why the line was so long! The employees can’t help other customers while waiting for your package to be delivered, and you can’t do anything else while you’re waiting either. How absurd, right?! We don’t need to stand there and wait for the delivery to be completed. We just need to know that the delivery was scheduled, and we’ll trust the system to work as it should.

The illustration is absurd, but this is exactly what happens in our web applications when we perform lengthy tasks in the middle of a request, before returning a response to the user. Perhaps a typical request can be serviced in 200 ms—but if we actually try to deliver an email in the middle of the request, that request may take 1 or 2 seconds to complete. For small web applications, this may be acceptable. But at a larger scale, this could mean you need additional expensive servers to handle the load.

This is exactly the type of problem which Active Job strives to solve. With Active Job, we can schedule a job (like email delivery) to be performed later, so that it doesn’t block the server from handling other requests and doesn’t block the client from going about their business too. Whenever we have a lengthy operation to perform in response to a request and the client doesn’t need to know immediately if the operation succeeded or not—just that it was scheduled to be performed—then using a job runner like Active Job is a great way to service these requests efficiently.

Active Job isn’t the first or only solution to this problem for Rails developers. For years, developers have solved this problem with cron jobs, custom software, or third-party job queuing frameworks, like Resque , Delayed::Job , and Sidekiq . Active Job doesn’t even necessarily replace these frameworks; it provides a simple default implementation of a job queueing framework and acts as an adapter so that a developer can switch between job queueing frameworks without needing to overhaul their code.

In this chapter, first we will learn about Active Job configuration. Then, we will explore the anatomy of an Active Job class to learn about its capabilities. Finally, we will improve the performance of our blog application by sending our emails through Active Job.

Configuring Active Job

You may be surprised to find out that not only is Active Job already installed in our blog application but we’ve already used it (indirectly). In the previous chapter, when we used the built-in tool to send an email to our blog application, some Action Mailbox and Active Storage jobs were scheduled and performed in order to analyze the submitted email and to route it to the appropriate Mailbox class. (If you’d like to see for yourself, revisit that section of the previous chapter and watch the server output when you submit the email. Look for lines that begin with [ActiveJob].)

As you can see, Rails makes using a job queueing system as easy as possible—no configuration necessary! However, it’s important to note that the default implementation which Active Job includes is not appropriate for production use, mainly because it stores the information about the scheduled jobs in memory—meaning that if your Rails server is stopped, it loses track of the jobs it might still need to perform.

But as mentioned before, Active Job also acts as an adapter to work with more robust job frameworks which are suitable for production environments; tools like Sidekiq, Delayed::Job, and Resque can keep track of jobs which need to be performed, offer administrative tools, and other advanced features. So this means we can use Active Job in development with no fuss and, when the need arises in production, can do a little extra work to integrate with a production-ready job runner—without needing to change how our jobs were written.

The only Active Job configuration option we’re likely to set is config.active_job.queue_adapter , which tells Active Job which job queueing system we want to use. Table 13-1 shows the most common values we might use for this option. As usual, we can configure these options in config/application.rb when we want the setting to apply across all environments or in each specific config/environments/*.rb file.
Table 13-1

Common Values for config.active_job.queue_adapter

Option

Description

:async

This is the default implementation provided by Active Job. It performs the jobs asynchronously—outside of the client/server request cycle. This adapter is only appropriate for development and testing, as it will lose track of scheduled jobs when the server process is restarted.

:inline

This is another implementation provided by Active Job. Unlike the async implementation, the inline implementation performs the jobs during the request cycle. This option loses the performance gains which the :async adapter provides, but may be necessary for custom Rake tasks which schedule jobs to work properly.

:test

This is another implementation provided by Active Job, meant to be used in your testing environment. This adapter lets your tests decide whether the jobs should actually be performed or not and makes it easy for your tests to assert whether or not certain jobs were queued or performed.

:backburner,

:delayed_job,

:que, :que_classic,

:resque, :sidekiq, :sneakers, :sucker_punch

These adapters are provided by Active Job, but require configuration and installation of a third-party job framework to actually queue and perform jobs. For production use, it's highly recommended to choose one of these alternatives.

We will stick with Active Job’s default :async adapter for now, so no configuration changes needed. But when you’re ready to use Active Job in production, see https://api.rubyonrails.org/v6.0.2.1/classes/ActiveJob/QueueAdapters.html for a list of supported adapters.

Creating an Active Job

We have described the problem that Active Job seeks to solve and explored its configuration a little bit—but how does one create a job?

Purely for illustration (and for fun), let’s create a silly job called GuessANumberBetweenOneAndTenJob. While this job won’t be useful for us in a practical sense, it will demonstrate various aspects of Active Job classes which will serve you practically in the future.

We’ll start out simple and then enhance this job as we go along. First, let’s use the Rails generator to create our Job class:
> rails g job guess_a_number_between_one_and_ten
    invoke  test_unit
    create  test/jobs/guess_a_number_between_one_and_ten_job_test.rb
    create  app/jobs/guess_a_number_between_one_and_ten_job.rb
Now, let’s edit app/jobs/guess_a_number_between_one_and_ten_job.rb so that it matches Listing 13-1.
class GuessANumberBetweenOneAndTenJob < ApplicationJob
  queue_as :default
  def perform(my_number)
    guessed_number = rand(1..10)
    if guessed_number == my_number
      Rails.logger.info "I guessed it! It was #{my_number}"
    else
      Rails.logger.error "Is it #{guessed_number}? No? Hmm."
    end
  end
end
Listing 13-1

app/jobs/guess_a_number_between_one_and_ten_job.rb https://gist.github.com/nicedawg/3189d0b82a40401a7d17ba1333cf1c2d

First, we see that our Job class inherits from ApplicationJob, which is defined in our application in app/jobs/application_job.rb. If you inspect ApplicationJob, you’ll see it inherits from ActiveJob::Base. This is similar to how our Active Record models, controllers, and mailers work. ApplicationJob provides a place to add functionality to all of our application’s jobs while also endowing each of our Job classes with all of Active Job’s functionality.

Next, we see queue_as :default . Active Job allows you to define separate queues for categorizing your jobs and treating them differently. For example, some jobs may be higher priority than others; you could put them in a queue named “critical,” for example, and configure your server to prioritize them.

Next, we see we defined a perform method. Our Job classes must always have a perform method; this is the method which will be executed when the job is performed. As you can see, you can provide arguments to your perform method.

Our perform method implements a simple game; we provide our number (which should be between 1 and 10), and the job will pick a random number between 1 and 10. If the random number matches the number we passed in, it declares victory in the logged output. If the random number doesn’t match our number (and most of the time it won’t), then it admits defeat in the logged output.

Performing a Job

Let’s try it out! Open your rails console (or reload! it), and let’s perform the job:
> rails c
irb(main):001:0> GuessANumberBetweenOneAndTenJob.new.perform(3)
Is it 5? No? Hmm.
=> true

Of course, since the job guesses randomly, your output is likely different. You may have even gotten lucky, and the job guessed your number on the first try! Go ahead and rerun the job until it finally guesses your number if you’d like. (On most systems, you can just press the “up” arrow on your keyboard to pull up the previous commands and then press Enter again.)

Performing a Job Later

Well, that was a little fun, maybe. But we didn’t make use of Active Job’s asynchronous execution of our job. Let’s go back to our rails console and run our job a little differently:
irb(main):002:0> GuessANumberBetweenOneAndTenJob.perform_later(3)
Enqueued GuessANumberBetweenOneAndTenJob (Job ID: fc5eb7b6-b1ab-4011-ba4c-cac73e999f3c) to Async(default) with arguments: 3
=> #<GuessANumberBetweenOneAndTenJob:0x00007fda1c172ae8 @arguments=[3], @job_id="fc5eb7b6-b1ab-4011-ba4c-cac73e999f3c", @queue_name="default", @priority=nil, @executions=0, @exception_executions={}, @provider_job_id="c1fe1985-449a-4100-812c-9bab5923694b">
irb(main):003:0> Performing GuessANumberBetweenOneAndTenJob (Job ID: fc5eb7b6-b1ab-4011-ba4c-cac73e999f3c) from Async(default) enqueued at 2020-03-28T18:48:45Z with arguments: 3
I guessed it! It was 3
Performed GuessANumberBetweenOneAndTenJob (Job ID: fc5eb7b6-b1ab-4011-ba4c-cac73e999f3c) from Async(default) in 4.99ms

As you can see, I got lucky this time, but you likely won’t. We ran our job a little differently—instead of .new.perform(3), we used perform_later(3). Our code in the perform method was still executed, but all the extra output from the rails console command shows us that this small change resulted in our job being “enqueued” and then “performed” later. Sure, it was only milliseconds later, but you get the idea.

Admittedly , that was maybe even less fun. It’s interesting to see the job being queued up and then performed asynchronously, but it didn’t add anything to our game. (But it did teach us how to execute our job asynchronously!) Let’s enhance our silly game, though.

Retrying a Failed Job

Next, let’s change our job so that it retries the job if it fails to guess the correct number. Modify your job so it looks like Listing 13-2.
class GuessANumberBetweenOneAndTenJob < ApplicationJob
  queue_as :default
  class GuessedWrongNumber < StandardError; end
  retry_on GuessedWrongNumber, attempts: 8, wait: 1
  def perform(my_number)
    guessed_number = rand(1..10)
    if guessed_number == my_number
      Rails.logger.info "I guessed it! It was #{my_number}"
    else
      raise GuessedWrongNumber, "Is it #{guessed_number}? No? Hmm."
    end
  end
end
Listing 13-2

Retrying Our Job When It Fails to Guess the Right Number https://gist.github.com/nicedawg/784bfce14529a6e5432dd5eb542b8c8c

Our changes were fairly minimal. First, we defined a custom exception called GuessedWrongNumber , which inherits from StandardError, as is common practice for custom exceptions. This syntax may look strange; we haven’t yet defined a class inside of another class, and the semicolon looks out of place. It’s okay, though; defining a class within another class is perfectly valid, and when all you need is inheritance, defining a class within a single line is valid too.

Next, we configured our job to retry when the GuessedWrongNumber exception is raised during execution of the job. The default for retry_on is five attempts, but we decided to be generous and give our job eight attempts to guess the right number. We could have also accepted the default of waiting 3 seconds between retries, but we chose to only wait 1 second.

Finally , instead of simply logging the error, we raise our custom exception with a custom error message, so that the retry_on behavior will kick in. This has the overall effect of retrying our job up to eight times, 1 second apart, when the job fails to guess the correct number.

Let’s go ahead and try this out in our rails console:
irb(main):003:0> reload!
irb(main):004:0> GuessANumberBetweenOneAndTenJob.perform_later(3)
Enqueued GuessANumberBetweenOneAndTenJob ....
Performing GuessANumberBetweenOneAndTenJob .....
Error performing GuessANumberBetweenOneAndTenJob... GuessANumberBetweenOneAndTenJob::GuessedWrongNumber (Is it 9? No? Hmm.): ...
 ... backtrace omitted ...
Retrying GuessANumberBetweenOneAndTenJob in 1 seconds, due to a GuessANumberBetweenOneAndTenJob::GuessedWrongNumber.
Performing GuessANumberBetweenOneAndTenJob ...
Error performing GuessANumberBetweenOneAndTenJob…
… backtrace omitted ...
Retrying GuessANumberBetweenOneAndTenJob in 1 seconds…
… many retries and their backtraces omitted …
Stopped retrying GuessANumberBetweenOneAndTenJob due to a GuessANumberBetweenOneAndTenJob::GuessedWrongNumber, which reoccurred on 8 attempts.

You may have noticed that we omitted a lot of output. The majority of the output we omitted (which you might be scrolling through) is from backtraces—a long list of file names, line numbers, and method names which show you the method calls that led to your exception. Those aren’t helpful to us right now—we know exactly where our GuessedWrongNumber exception came from. But in real-world debugging, it’s often helpful to look closely at these backtraces to establish a context for the conditions in which an error occurred.

Skipping over the backtraces, you’ll see a series of messages from Active Job which inform you that it has enqueued your job, that it’s performing it, that an error occurred, that it’s going to retry the job, and then perhaps that it finally gave up because it reached the maximum retry attempts allowed.

While this is a silly example, being able to retry your jobs when certain exceptions occur is very useful. For example, maybe your job consumes a third-party API; if that third-party API has a brief outage and your job is written to handle the exception that such an outage might raise, your job can be smart enough to try again later, when it very well may succeed. By expecting and handling such exceptions, we can develop more robust applications.

Discarding a Failed Job

Sometimes, when a certain exception is raised, we may want to discard our job. In certain situations, the job may no longer be applicable. For instance, perhaps a job is run to update a particular article—but by the time the job is performed, that article has been destroyed and can no longer be found.

Similar to retry_on, Active Job gives us the ability to call discard_on for certain exceptions. To illustrate this, we’ll discard our job in the event that the number we provide isn’t an integer between 1 and 10. Let’s modify our job so it matches Listing 13-3.
class GuessANumberBetweenOneAndTenJob < ApplicationJob
  class ThatsNotFair < StandardError; end
  class GuessedWrongNumber < StandardError; end
  discard_on ThatsNotFair
  retry_on GuessedWrongNumber, attempts: 8, wait: 1
  def perform(my_number)
    unless my_number.is_a?(Integer) && my_number.between?(1, 10)
      raise ThatsNotFair, "#{my_number} isn't an integer between 1 and 10!"
    end
    guessed_number = rand(1..10)
    if guessed_number == my_number
      Rails.logger.info "I guessed it! It was #{my_number}"
    else
      raise GuessedWrongNumber, "Is it #{guessed_number}? No? Hmm."
    end
  end
end
Listing 13-3

Discarding Our Job When Provided an Invalid Numberhttps://gist.github.com/nicedawg/80779e72918f83c86a81bd5115b92271

Similar to how we added the ability to retry, we added the ability to discard the job by first defining a custom exception called ThatsNotFair . Then, we configured the job to be discarded when the ThatsNotFair exception is raised during job execution. Finally, we added some logic to the perform method to raise our custom exception (with a custom error message) if we tried to cheat the system by providing a number which isn’t an integer between 1 and 10.

Let’s try it out in the rails console:
irb(main):043:0> GuessANumberBetweenOneAndTenJob.perform_later(11)
Enqueued GuessANumberBetweenOneAndTenJob ....
Performing GuessANumberBetweenOneAndTenJob ...
Error performing GuessANumberBetweenOneAndTenJob .... GuessANumberBetweenOneAndTenJob::ThatsNotFair (11 isn't an integer between 1 and 10!):
... backtrace omitted ...
Discarded GuessANumberBetweenOneAndTenJob due to a GuessANumberBetweenOneAndTenJob::ThatsNotFair

Again, this is a silly example, but it shows us how we can choose how to handle certain exceptions in our jobs—sometimes by retrying, sometimes by discarding.

Improving Our Blog with Active Job

How can we use what we’ve learned about Active Job to improve our blog application? What long-running tasks do we have which we can defer to a background process to speed up our response time, so that both the client and server can move on to submitting and responding to more requests?

Converting our email delivery to use Active Job for asynchronous delivery is the lowest-hanging fruit. Thankfully, this is such a common need that we won’t have to write custom Job classes to manage asynchronous email delivery; Action Mailer anticipated our need and provided us with a deliver_later method we can use (instead of simply using deliver) to convert our email delivery from happening in the middle of our request cycle to happening outside of the request cycle.

In the previous chapter, we could have opted to use deliver_later; it would have worked just fine, with no installation or configuration necessary. However, we decided to introduce it in this chapter so you could appreciate its usefulness (and understand better what’s happening behind the scenes).

Before we begin converting our emails to be delivered asynchronously with deliver_later, let’s do a little casual benchmarking of the current performance of our requests which send email synchronously.

For example, let’s use the “Email a Friend” form on an article’s show page. (Hopefully in the previous example, you were able to successfully deliver email from your application. But even if not, you should still be able to see the performance improvements of handing your deliveries off to Active Job.)

Go ahead and send yourself an email using the “Email a Friend” form, and then look at the server output for something that looks like the following:
Started POST "/articles/1/notify_friend" for ::1 at 2020-03-28 15:21:42 -0500
Processing by ArticlesController#notify_friend as JS
... output omitted ...
NotifierMailer#email_friend: processed outbound mail in 18.0ms
Delivered mail 5e7fb1d6e22ea_9abe3feeb76d503412331@Bradys-MacBook-Pro-2.local.mail (2685.5ms)
... email contents omitted ...
Redirected to http://localhost:3000/articles/1
Completed 200 OK in 2758ms (ActiveRecord: 0.6ms | Allocations: 20009)

We omitted some of the output for clarity. Look for the line that signifies that the request for “POST /articles/:id/notify_friend” began, and then look for the numbers that correspond with that request. In my example, it took 2685 ms (2.6 s) to deliver the email. The request as a whole (including the email delivery) took about 2.7 seconds to process.

Try it a few more times, taking note of these numbers, and establish an idea of the average response time. You’re likely to see a bit of a range, but perhaps an average response time of around 2 seconds, depending on your development machine, as well as the performance of the email provider you’re using to send your email.

Now, let’s convert this mailer to deliver asynchronously using Active Job. Simply edit your app/controllers/article_controller.rb to match Listing 13-4.
class ArticlesController < ApplicationController
  before_action :authenticate, except: [:index, :show]
  before_action :set_article, only: [:show, :notify_friend]
  .. code omitted ...
  def notify_friend
    NotifierMailer.email_friend(@article, params[:name], params[:email]).deliver_later
    redirect_to @article, notice: 'Successfully sent a message to your friend'
  end
  ... code omitted ...
end
Listing 13-4

Sending “Email a Friend” Using Active Job https://gist.github.com/nicedawg/52a9cdf57e671d41de0e6044c4d5b555

With that small change, now try sending that same email to yourself by filling out the “Email a Friend” form again. Let’s try a few times to see what the new average response time is:
Started POST "/articles/1/notify_friend" ...
Processing by ArticlesController#notify_friend as JS
… output omitted ...
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob (Job ID: cdcfdc12-7275-4faf-bd9a-d546acb54688) to Async(mailers) with arguments: "NotifierMailer", "email_friend", "deliver_now", ….
Redirected to http://localhost:3000/articles/1
Completed 200 OK in 42ms (ActiveRecord: 0.7ms | Allocations: 19614)
… output omitted ...
Started GET "/articles/1" …
[ActiveJob] [ActionMailer::MailDeliveryJob] Performing...
[ActiveJob] [ActionMailer::MailDeliveryJob] [cdcfdc12-7275-4faf-bd9a-d546acb54688] Delivered mail(2292.7ms)
[ActiveJob] [ActionMailer::MailDeliveryJob] [cdcfdc12-7275-4faf-bd9a-d546acb54688] Performed ... in 2337.68ms

Reading logs can be a bit tricky, so we omitted some output to focus on the important parts. We see that the response to the “notify_friend” request was drastically reduced from somewhere in the realm of 2 seconds to 40 milliseconds. We can now process about 50 of these requests in the time it used to take to handle one!

One might be tempted to say, “Big deal, 2 seconds isn’t long at all.” However, in a production environment, a performance increase like this is very valuable. Not only will your users appreciate a snappier response time but forcing browsers to wait for the email to be delivered (like the illustration of the Post Office at the beginning of this chapter) will lead to long lines of customers waiting for someone to be able to handle their request. They’ll eventually get tired of waiting and give up or receive an error. However, with a job framework like Active Job, we can easily defer certain time-consuming tasks to be performed later for an easy win.

While we’re at it, let’s go ahead and convert our other mailer deliveries to use Active Job too. Listings 13-5 and 13-6 show where to change our remaining usages of deliver to deliver_later in order to speed up our response times.
class Comment < ApplicationRecord
  belongs_to :article
  validates :name, :email, :body, presence: true
  validate :article_should_be_published
  after_create :email_article_author
  def article_should_be_published
    errors.add(:article_id, 'is not published yet') if article && !article.published?
  end
  def email_article_author
    NotifierMailer.comment_added(self).deliver_later
  end
end
Listing 13-5

Sending “Comment added” Mailer Asynchronously https://gist.github.com/nicedawg/c28d5a14e7182d606ef5bf01b68779ee

class DraftArticlesMailbox < ApplicationMailbox
  before_processing :require_author
  def process
    article = author.articles.create!(
      title: mail.subject,
      body: mail.body,
    )
    DraftArticlesMailer.created(mail.from, article).deliver_later
  end
  private
  def require_author
    bounce_with DraftArticlesMailer.no_author(mail.from) unless author
  end
  def author
    @author ||= User.find_by(draft_article_token: token)
  end
  def token
    mail.to.first.split('@').first
  end
end
Listing 13-6

Sending “Draft article created” Mailer Asynchronously https://gist.github.com/nicedawg/64cda2012f5dcf9662561480f0c3ba31

Summary

In this chapter, we learned about Active Job and the types of problems it solves. To illustrate some of Active Job’s capabilities, we created a silly game using Active Job and learned how to invoke a job synchronously and asynchronously via the rails console. We then learned how to retry and discard jobs in reaction to certain types of errors.

Finally, we learned how easy it is to convert our mail deliveries to use Active Job and saw how it greatly improved response times when a request attempted to deliver an email.

While we stuck with Rails’ default :async adapter for convenience, we learned that we should choose a more robust job backend for production usage—but that our Job classes wouldn’t necessarily need to change when switching backends.

What’s next? While testing out our improvements to our email delivery, you may have realized that our “Email a Friend” form isn’t very robust. If you submit it with blank or invalid information, our blog acts like that’s perfectly fine and even says, “Successfully sent a message to your friend.” If submitting this form led to the creation of an Active Record model, we could simply add validations to that model to fix this problem, but it doesn’t create an instance of an Active Record model. In the next chapter, we’ll explore Active Model and learn how we can use it to add validations and other helpful things to classes which aren’t stored in the database.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.122.4