In Progress
Unit 1, Lesson 1
In Progress

Explore and Extend – Part 1

Sometimes there’s a vital piece missing from a library or framework we’re using. For instance, sometimes we find we need to backport a feature or fix from the development branch of a RubyGem we depend on. Ruby gives us the tools to make these kinds of dynamic modifications to third-party code, but it’s not a procedure to be taken on lightly.

Today, guest chef Jordan Raine re-joins us to demonstrate a practical, responsible approach to this problem. This episode builds on the method introspection techniques he covered in episode 540, so if you haven’t seen it yet be sure to watch that one before diving in.

Enjoy!

Video transcript & code

Prelude: Episode #540: Ruby Spelunking

Part 1: Inheritance

Today, we'll be looking at a lightly adapted story from a real world situation. While working on a Rails 4.2 application that uses ActiveJob with Sidekiq, we need to add something new to the serialization and deserialization of jobs.

Each request into the app is assigned a UUID. When the request logs a message, it is tagged with that UUID, making it possible to see all messages from a single request.

However, lately we've started to use background jobs more frequently and now want to tag every log message from jobs with the same UUID as the request that enqueued them.


Trace.uuid = "1234"
Rails.logger.info("Request started") # [trace_uuid:1234] Request started
Rails.logger.info("Request complete") # [trace_uuid:1234] Request complete
MyJob.perform_later # Any log messages are tagged with "1234"

So, we need to extend how ActiveJob serializes and deserializes jobs. What's the best way to do this? To find out, let's go spelunking.

To help us experiment, let’s create a simple job that prints trace_uuid to STDOUT.


class MyJob < ApplicationJob
  def perform
    puts "TRACE UUID: #{trace_uuid.inspect}"
  end
end

Let’s also add pry-rails to our Gemfile so we can read the source of methods at the Rails console.


source 'https://rubygems.org'

gem 'pry-rails'

# ...

Let’s start with what we know: when you want a job to run in the background, you call perform_later. Looking at the source of perform_later, we can see it calls the enqueue method.


MyJob.method(:perform_later).source.display
#     def perform_later(*args)
#       job_or_instantiate(*args).enqueue
#     end
# => nil

To make sure we’re looking at the right method, let’s call job_or_instantiate and then grab the enqueue method from that.


MyJob.job_or_instantiate.method(:enqueue)
# NoMethodError: protected method `job_or_instantiate' called for MyJob:Class

Oops, it looks like job_or_instantiate is a protected method. No worries, this is Ruby. Let’s use s1end to get around the method visibility and try again.


MyJob.send(:job_or_instantiate).method(:enqueue).source.display
#     def enqueue(options={})
#       self.scheduled_at = options[:wait].seconds.from_now.to_f if options[:wait]
#       self.scheduled_at = options[:wait_until].to_f if options[:wait_until]
#       self.queue_name   = self.class.queue_name_from_part(options[:queue]) if options[:queue]
#       run_callbacks :enqueue do
#         if self.scheduled_at
#           self.class.queue_adapter.enqueue_at self, self.scheduled_at
#         else
#           self.class.queue_adapter.enqueue self
#         end
#       end
#       self
#     end
# => nil

Reading through the method, it looks like the next step is a call a different method called enqueue but this time it's on the queue_adapter.


MyJob.queue_adapter.method(:enqueue).source.display
#         def enqueue(job) #:nodoc:
#           #Sidekiq::Client does not support symbols as keys
#           Sidekiq::Client.push \
#             'class' => JobWrapper,
#             'wrapped' => job.class.to_s,
#             'queue' => job.queue_name,
#             'args'  => [ job.serialize ]
#         end
# => nil

This pushes the job into a Sidekiq queue and at the bottom of the method it calls job.serialize to get a serialized version of the job. This is what we’ve been looking for so let’s take a closer look.

It’s a straightforward method that creates a hash from attributes and global values and then returns it.


MyJob.new.method(:serialize).source.display
#     def serialize
#       {
#         'job_class'  => self.class.name,
#         'job_id'     => job_id,
#         'queue_name' => queue_name,
#         'arguments'  => serialize_arguments(arguments),
#         'locale'     => I18n.locale
#       }
#     end
# => nil

After a few dives, we’ve found the first half of the puzzle: where and how serialization works. Thankfully, it’s an instance method on each job that can be overridden so...

...let’s implement the new behaviour we want in ApplicationJob#serialize by adding a trace_uuid attribute, calling super, and then merging trace_uuid into the hash – either using the existing trace_uuid value or the global value.


class ApplicationJob < ActiveJob::Base attr_accessor :trace_uuid def serialize super.merge("trace_uuid" => trace_uuid || Trace.uuid)
  end
end

Back on the console, we can set the global trace UUID, instantiate a new job, and then serialize that job. The return value looks sane and, taking a closer look, we can see that trace_uuid is also being set.


Trace.uuid = "abcdef"
job = MyJob.new
job.serialize
# => {"job_class"=>"MyJob", "job_id"=>"3950bb41-1405-45f4-9cc2-d09499d75499", "queue_name"=>"default", "arguments"=>[], "locale"=>:en, "trace_uuid"=>"abcdef"}
job.serialize["trace_uuid"]
# => "abcdef"

Perfect! This is working great. It pulls the current trace UUID and adds it to the serialized hash without impacting any existing behaviour from ActiveJob::Base. Without leaving the console, we learned how serialization works under-the-hood and extended it for our own needs.

In the next episode, we'll tackle the other half—deserialization—and learn how to extend code that isn't designed for it.

Responses