Monkey patching is bad. That’s where you should start from. It can cause trouble where you’d least expect it, conflicts with libraries you’d least expect in ways you’d least expect. And yet here I am sharing code for patching the delayed_job gem to (more or less) work with Ruby 3. Doesn’t this violate my own policies? There are a few choices.

  1. give up upgrading to Ruby 3 altogether
  2. monkey patch delayed_job as an emergency fix and make time to figure out what to do
  3. contribute to delayed_job making sure the gem is solid on Ruby 3
  4. get rid of all the .delay calls and switch to another async job library

As for 1, it’s not really something I want to consider. The whole point is to upgrade to Ruby 3. Having to back off from such a major goal for this quarter wouldn’t be nice. 2 isn’t an ideal solution, but more on that later.

3 would be nice, except there is already a pull request at the delayed_job repo for Ruby 3 support. The problem is it’s been there, avoided like a dead fish for almost a whole year now. I have to notice that other projects in the collectiveidea org are having similar fates. I don’t expect this to change today or tomorrow.

4 would be nice too, except it’s simply too much work for the short term. There are still almost 100 .delay calls in the codebase I work with that need to be replaced. There is also the infra cost. delayed_job has some peculiar behaviors that our infra team have worked around over the years. All of that would need to be reviewed and readjusted for whatever library is chosen as a replacement (even with delayed that implemented Ruby 3 support). It is something that will have to be done eventually, considering delayed_job appears to be dead for good. But it isn’t something I could pull off alone in a week.

This brings us back to 2. Improving on the Ruby 3 support PR, it’s possible to get delayed_job to work with Ruby 3 with relatively minimal changes. The whole problem is that with Ruby 3, the behavior of (splatting) keyword arguments is changed in a non-backwards compatible way. They did add deprecation warnings from Ruby 2.7, giving users plenty of time to adjust (though it’s still a huge pain in the ass), except remember that part about delayed_job being dead? That causes issues.

You can override Delayed::PerformableMethod‘s perform and get it to work in most cases. It probably breaks in a variety of cases (like Rails Mailers or Ruby 2.6 and below) so don’t do it unless you have good test coverage and know what you’re doing. If the last passed argument is a Hash and the callee method accepts keyword arguments, it handles that, otherwise falls back to the default single * splat.

class Delayed::PerformableMethod
  def perform
    return unless object
    callee = object.method(method_name)
    if args.last.is_a?(Hash) &&
       callee.parameters.any? { |(kind, _)| kind.in?(%i[key keyreq keyrest]) }
      if args.size == 1
        object.send method_name, **args.last
      else
        object.send method_name, *args.butlast, **args.last
      end
    else
      object.send method_name, *args
    end
  end
end