Why Rails' ActiveJob enqueuing was designed to be so lax

(Context: once in a while I bump into something which seems “obviously wrong” on first sight, but my experience has taught me that, with dozens or more smart people working and using this, I’m probably just lacking context and the “obviously wrong” thing actually makes sense)

Let’s assume we have a job MyJob with

perform(param1:, param:2)

now, when instantiating or enqueing it:

job = MyJob.new(param1:)
ActiveJob.perform_all_later([job])
# or
MyJob.perform_later(param1:)

the enqueing happens without issue, but the task fails because of the missing keyword param:2 during execution.

I’m interested in why was it designed like this?

My understanding so far is this:

Let’s assume we have a web request (user submits a form) and this request is supposed to perform a heavy operation in the background. The request will succeed, but the job will fail. So now, the dev can see what’s wrong and “update” the enqueued job, and trigger it to run again? But that’s only if the missing data can be found. I can imagine there would be some cases where app developers would prefer to “fail early”.

Another reason: maybe it’s too hacky to inspect the signature of the perform method, and it’s not worth it?

Or perhaps it is not how we’d prefer it to be, but with backwards compatibility in mind it’s been decided it’s not worth touching (yet)?

I didn’t manage to find relevant discussions on the internet, and I hope there’s someone here that remembers discussions and deliberations about the tradeoffs.

Inspecting a method signature is possible, but you need to cover a lot of cases with variadic args, defaults, keyword args, etc.

I’m assuming the ActiveJob maintainers just didn’t want to write and maintain all that extra code.

Looking at this another way, a faulty method signature is just one of an almost infinite number of possible failures. Why test for that potential problem alone?

Another reason in part, I think is cause the initialize method looks like:

    # Creates a new job instance. Takes the arguments that will be
    # passed to the perform method.
    def initialize(*arguments)
      @arguments  = arguments
      @job_id     = SecureRandom.uuid
      @queue_name = self.class.queue_name
      @scheduled_at = nil
      @priority   = self.class.priority
      @executions = 0
      @exception_executions = {}
      @timezone   = Time.zone&.name
    end
    ruby2_keywords(:initialize)

I think it’d be unnecessary and not-performant to check the perform method signature every time and ensure @arguments matches. Let the job handle its own failure, or do your checks before passing the variables in.

If you wanted to add your own check, you could define a before_enqueue to check the arguments are there. I did something similar with my async method library which did parse parameters and recreate them so I could bubble argument errors immediately.

1 Like

I can’t say why it was designed this way, but it seems possible to make it work the way you want. Maybe start by defining your own perform_later class method on MyJob which has the signature you want and calls super. If this works the way you want, I’m sure we could find a way to automatically generate such class methods and it shouldn’t be much overhead. We’re talking one additional method call. It does add a little more complexity, but it sounds rather nice to me!

Slightly off topic, but there’s actually a relatively straightforward way to inspect signature.

You construct a dummy signature using parameters of the original method:

dummy_signature =
  method(:method_name).parameters.map { |type, name|
    case type
    when :req;     name.to_s
    when :opt;     "#{name} = 'value'"
    when :rest;    "*#{name}"
    when :key;     "#{name}: 'value'"
    when :keyreq;  "#{name}:"
    when :keyrest; "**#{name}"
    when :block;   "&#{name}"
    end
  }.join(', ')

Then you create a dummy method with that signature:

obj = Object.new
eval "def obj.verify_args(#{dummy_signature}); end"

Then you pass the attempted parameters into it, and see if there’s an argument error.

begin
  obj.verify_args(*args, **kwargs, &block)
rescue ArgumentError
  puts 'Incompatible parameters'
end

I had this idea sitting around because of a proposal for verifying stubs in minitest some time ago. Ended up using it for stub_strict in our own project for now.

What a wonderful hack!

We did something similiar with our memoization gem (memoized), where we wanted to preserve #parameters and #arity for memoized methods.

1 Like

Sorry to hijack the thread, but ain’t that funny, that file rhymes quite a bit with the one I developed for an async methods gem that utilizes active job.

I’d be surprised if the “only” (or most reasonably intuitive, given I did, and I assume you also searched for alternative) solution(s) involve (manual) method signature parsing and code generation, instead of being able to attach parameter tokens directly to a new method to copy a method signature. I’d at least be interested in finding out if the parser gem provides similar functionality.

Will post back here if I ever do look into it if you’re interested :slight_smile: