ActiveJob: how to do simple operations without a full blown job class?

950 views Asked by At

With delayed_job, I was able to do simple operations like this:

@foo.delay.increment!(:myfield)

Is it possible to do the same with Rails' new ActiveJob? (without creating a whole bunch of job classes that do these small operations)

3

There are 3 answers

0
D-side On BEST ANSWER

ActiveJob is merely an abstraction on top of various background job processors, so many capabilities depend on which provider you're actually using. But I'll try to not depend on any backend.

Typically, a job provider consists of persistence mechanism and runners. When offloading a job, you write it into persistence mechanism in some way, then later one of the runners retrieves it and runs it. So the question is: can you express your job data in a format, compatible with any action you need?

That will be tricky.

Let's define what is a job definition then. For instance, it could be a single method call. Assuming this syntax:

Model.find(42).delay.foo(1, 2)

We can use the following format:

{
  class: 'Model',
  id: '42', # whatever
  method: 'foo',
  args: [
    1, 2
  ]
}

Now how do we build such a hash from a given call and enqueue it to a job queue?

First of all, as it appears, we'll need to define a class that has a method_missing to catch the called method name:

class JobMacro
  attr_accessor :data
  def initialize(record = nil)
    self.data = {}
    if record.present?
      self.data[:class] = record.class.to_s
      self.data[:id]    = record.id
    end
  end
  def method_missing(action, *args)
    self.data[:method] = action.to_s
    self.data[:args] = args
    GenericJob.perform_later(data)
  end
end

The job itself will have to reconstruct that expression like so:

data[:class].constantize.find(data[:id]).public_send(data[:method], *data[:args])

Of course, you'll have to define the delay macro on your model. It may be best to factor it out into a module, since the definition is quite generic:

def delay
  JobMacro.new(self)
end

It does have some limitations:

  • Only supports running jobs on persisted ActiveRecord models. A job needs a way to reconstruct the callee to call the method, I've picked the most probable one. You can also use marshalling, if you want, but I consider that unreliable: the unmarshalled object may be invalid by the time the job gets to execute. Same about "GlobalID".
  • It uses Ruby's reflection. It's a tempting solution to many problems, but it isn't fast and is a bit risky in terms of security. So use this approach cautiously.
  • Only one method call. No procs (you could probably do that with ruby2ruby gem). Relies on job provider to serialize arguments properly, if it fails to, help it with your own code. For instance, que uses JSON internally, so whatever works in JSON, works in que. Symbols don't, for instance.

Things will break in spectacular ways at first.
So make sure to set up your debugging tools before starting off.


An example of this is Sidekiq's backward (Delayed::Job) compatibility extension for ActiveRecord.

0
Simone Carletti On

As far as I know, this is currently not supported. You can easily simulate this feature using a custom-defined proxy-job that accepts a model or instance, a method to be performed and a list of arguments.

However, for the sake of code testing and maintainability, this shortcut is not a good approach. It's more effective (even if you need to write a little bit more of code) to have a specific job for everything you want to enqueue. It forces you to think more about the design of your app.

0
Cristian Bica On

I wrote a gem that can help you with that https://github.com/cristianbica/activejob-perform_later. But be aware that I believe that having methods all around your code that might be executed in workers is the perfect recipe for disaster is not handled carefully :)