Skip to content

Commit 6d9d831

Browse files
authored
Support Ruby 3.0 kwarg-handling within .delay API (#7)
Resolves #6. While we already fully support Ruby 3.0 kwargs via ActiveJob, the legacy `delay` and `handle_asynchronously` APIs did not support the new [separation of positional and keyword arguments](https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/) introduced in 2.7 and enforced in 3.0. This means that methods accepting both positional and keyword arguments (e.g. `def foo(a, b:)`) would fail with a `wrong number of arguments (given 2, expected 1; required keyword:)` error on Ruby 3. In order to resolve this, this PR changes `Delayed::PerformableMethod` to handle kwargs separately from args, with a backwards compatibility check for any Ruby 2.6 methods that do not accept keyword arguments. It should also support jobs that were enqueued by the prior `delayed` gem version (where the new `kwargs` accessor would be nil, and `args` contains the kwargs as its last item).
1 parent 2d296b9 commit 6d9d831

12 files changed

+190
-92
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ and this project aims to adhere to [Semantic Versioning](http://semver.org/spec/
1111
### Removed <!-- for now removed features. -->
1212
### Fixed <!-- for any bug fixes. -->
1313

14+
## [0.4.0] - 2021-11-30
15+
### Fixed
16+
- Fix Ruby 3.0 kwarg compatibility issue when executing jobs enqueued via the
17+
`Delayed::MessageSending` APIs (`.delay` and `handle_asynchronously`).
18+
### Changed
19+
- `Delayed::PerformableMethod` now splits `kwargs` out into a separate attribute, while still being
20+
backwards-compatible with jobs enqueued via the previous gem version. This is an undocumented
21+
internal API and is not considered a breaking change, but if you had previously relied on
22+
`payload_object.args.last` to access keyword arguments, you must now use `payload_object.kwargs`.
23+
1424
## [0.3.0] - 2021-10-26
1525
### Added
1626
- Add more official support for Rails 7.0 (currently alpha2). There were no gem conflicts, but this
@@ -43,6 +53,7 @@ and this project aims to adhere to [Semantic Versioning](http://semver.org/spec/
4353
ancestor repos (`delayed_job` and `delayed_job_active_record`), plus the changes from Betterment's
4454
internal forks.
4555

56+
[0.4.0]: https://github.com/betterment/delayed/compare/v0.3.0...v0.4.0
4657
[0.3.0]: https://github.com/betterment/delayed/compare/v0.2.0...v0.3.0
4758
[0.2.0]: https://github.com/betterment/delayed/compare/v0.1.1...v0.2.0
4859
[0.1.1]: https://github.com/betterment/delayed/compare/v0.1.0...v0.1.1

delayed.gemspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ Gem::Specification.new do |spec|
1818
spec.require_paths = ['lib']
1919
spec.summary = 'a multi-threaded, SQL-driven ActiveJob backend used at Betterment to process millions of background jobs per day'
2020

21-
spec.version = '0.3.0'
21+
spec.version = '0.4.0'
2222
spec.metadata = {
2323
'changelog_uri' => 'https://github.com/betterment/delayed/blob/main/CHANGELOG.md',
2424
'bug_tracker_uri' => 'https://github.com/betterment/delayed/issues',
2525
'source_code_uri' => 'https://github.com/betterment/delayed',
26+
'rubygems_mfa_required' => 'true',
2627
}
2728
spec.required_ruby_version = '>= 2.6'
2829

lib/delayed/message_sending.rb

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ def initialize(payload_class, target, options)
88
@options = options
99
end
1010

11-
def method_missing(method, *args)
12-
Job.enqueue({ payload_object: @payload_class.new(@target, method.to_sym, args) }.merge(@options))
11+
def method_missing(method, *args, **kwargs)
12+
Job.enqueue({ payload_object: @payload_class.new(@target, method.to_sym, args, kwargs) }.merge(@options))
1313
end
1414
end
1515

@@ -18,16 +18,6 @@ def delay(options = {})
1818
DelayProxy.new(PerformableMethod, self, options)
1919
end
2020
alias __delay__ delay
21-
22-
def send_later(method, *args)
23-
warn '[DEPRECATION] `object.send_later(:method)` is deprecated. Use `object.delay.method'
24-
__delay__.__send__(method, *args)
25-
end
26-
27-
def send_at(time, method, *args)
28-
warn '[DEPRECATION] `object.send_at(time, :method)` is deprecated. Use `object.delay(:run_at => time).method'
29-
__delay__(run_at: time).__send__(method, *args)
30-
end
3121
end
3222

3323
module MessageSendingClassMethods
@@ -36,7 +26,7 @@ def handle_asynchronously(method, opts = {}) # rubocop:disable Metrics/Perceived
3626
punctuation = $1 # rubocop:disable Style/PerlBackrefs
3727
with_method = "#{aliased_method}_with_delay#{punctuation}"
3828
without_method = "#{aliased_method}_without_delay#{punctuation}"
39-
define_method(with_method) do |*args|
29+
define_method(with_method) do |*args, **kwargs|
4030
curr_opts = opts.clone
4131
curr_opts.each_key do |key|
4232
next unless (val = curr_opts[key]).is_a?(Proc)
@@ -47,7 +37,7 @@ def handle_asynchronously(method, opts = {}) # rubocop:disable Metrics/Perceived
4737
val.call
4838
end
4939
end
50-
delay(curr_opts).__send__(without_method, *args)
40+
delay(curr_opts).__send__(without_method, *args, **kwargs)
5141
end
5242

5343
alias_method without_method, method

lib/delayed/performable_mailer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
module Delayed
44
class PerformableMailer < PerformableMethod
55
def perform
6-
mailer = object.send(method_name, *args)
6+
mailer = super
77
mailer.respond_to?(:deliver_now) ? mailer.deliver_now : mailer.deliver
88
end
99
end

lib/delayed/performable_method.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
module Delayed
22
class PerformableMethod
3-
attr_accessor :object, :method_name, :args
3+
attr_accessor :object, :method_name, :args, :kwargs
44

5-
def initialize(object, method_name, args)
5+
def initialize(object, method_name, args, kwargs)
66
raise NoMethodError, "undefined method `#{method_name}' for #{object.inspect}" unless object.respond_to?(method_name, true)
77

88
if !her_model?(object) && object.respond_to?(:persisted?) && !object.persisted?
@@ -11,6 +11,7 @@ def initialize(object, method_name, args)
1111

1212
self.object = object
1313
self.args = args
14+
self.kwargs = kwargs
1415
self.method_name = method_name.to_sym
1516
end
1617

@@ -23,7 +24,13 @@ def display_name
2324
end
2425

2526
def perform
26-
object.send(method_name, *args) if object
27+
return unless object
28+
29+
if kwargs.nil? || (RUBY_VERSION < '2.7' && kwargs.empty?)
30+
object.send(method_name, *args)
31+
else
32+
object.send(method_name, *args, **kwargs)
33+
end
2734
end
2835

2936
def method(sym)

lib/delayed/psych_ext.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def encode_with(coder)
66
'object' => object,
77
'method_name' => method_name,
88
'args' => args,
9+
'kwargs' => kwargs,
910
}
1011
end
1112
end

spec/delayed/active_job_adapter_spec.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,25 @@ def perform; end
223223
end
224224
end
225225

226+
context 'when ActiveJob has both positional and keyword arguments' do
227+
let(:job_class) do
228+
Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
229+
cattr_accessor(:result)
230+
231+
def perform(arg, kwarg:)
232+
self.class.result = [arg, kwarg]
233+
end
234+
end
235+
end
236+
237+
it 'passes arguments through to the perform method' do
238+
JobClass.perform_later('foo', kwarg: 'bar')
239+
240+
Delayed::Worker.new.work_off
241+
expect(JobClass.result).to eq %w(foo bar)
242+
end
243+
end
244+
226245
context 'when using the ActiveJob test adapter' do
227246
let(:queue_adapter) { :test }
228247

spec/delayed/job_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ def create_job(opts = {})
352352
context 'large handler' do
353353
before do
354354
text = 'Lorem ipsum dolor sit amet. ' * 1000
355-
@job = described_class.enqueue Delayed::PerformableMethod.new(text, :length, {})
355+
@job = described_class.enqueue Delayed::PerformableMethod.new(text, :length, [], {})
356356
end
357357

358358
it 'has an id' do

spec/message_sending_spec.rb

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,27 @@
77
end
88

99
describe 'handle_asynchronously' do
10-
class Story
11-
def tell!(_arg); end
12-
handle_asynchronously :tell!
10+
let(:test_class) do
11+
Class.new do
12+
def tell!(_arg, _kwarg:); end
13+
handle_asynchronously :tell!
14+
end
1315
end
1416

1517
it 'aliases original method' do
16-
expect(Story.new).to respond_to(:tell_without_delay!)
17-
expect(Story.new).to respond_to(:tell_with_delay!)
18+
expect(test_class.new).to respond_to(:tell_without_delay!)
19+
expect(test_class.new).to respond_to(:tell_with_delay!)
1820
end
1921

2022
it 'creates a PerformableMethod' do
21-
story = Story.create
23+
obj = test_class.new
2224
expect {
23-
job = story.tell!(1)
25+
job = obj.tell!('a', kwarg: 'b')
2426
expect(job.payload_object.class).to eq(Delayed::PerformableMethod)
2527
expect(job.payload_object.method_name).to eq(:tell_without_delay!)
26-
expect(job.payload_object.args).to eq([1])
27-
}.to(change { Delayed::Job.count })
28+
expect(job.payload_object.args).to eq(['a'])
29+
expect(job.payload_object.kwargs).to eq(kwarg: 'b')
30+
}.to change { Delayed::Job.count }.by(1)
2831
end
2932

3033
describe 'with options' do
@@ -64,26 +67,33 @@ def spin; end
6467
end
6568

6669
context 'delay' do
67-
class FairyTail
68-
attr_accessor :happy_ending
70+
let(:fairy_tail_class) do
71+
Class.new do
72+
attr_accessor :happy_ending
6973

70-
def self.princesses; end
74+
def self.princesses; end
7175

72-
def tell
73-
@happy_ending = true
76+
def tell(arg, kwarg:)
77+
@happy_ending = [arg, kwarg]
78+
end
7479
end
7580
end
7681

82+
before do
83+
stub_const('FairyTail', fairy_tail_class)
84+
end
85+
7786
after do
7887
Delayed::Worker.default_queue_name = nil
7988
end
8089

8190
it 'creates a new PerformableMethod job' do
8291
expect {
83-
job = 'hello'.delay.count('l')
92+
job = FairyTail.new.delay.tell('arg', kwarg: 'kwarg')
8493
expect(job.payload_object.class).to eq(Delayed::PerformableMethod)
85-
expect(job.payload_object.method_name).to eq(:count)
86-
expect(job.payload_object.args).to eq(['l'])
94+
expect(job.payload_object.method_name).to eq(:tell)
95+
expect(job.payload_object.args).to eq(['arg'])
96+
expect(job.payload_object.kwargs).to eq(kwarg: 'kwarg')
8797
}.to change { Delayed::Job.count }.by(1)
8898
end
8999

@@ -111,8 +121,8 @@ def tell
111121
fairy_tail = FairyTail.new
112122
expect {
113123
expect {
114-
fairy_tail.delay.tell
115-
}.to change { fairy_tail.happy_ending }.from(nil).to(true)
124+
fairy_tail.delay.tell('a', kwarg: 'b')
125+
}.to change { fairy_tail.happy_ending }.from(nil).to %w(a b)
116126
}.not_to(change { Delayed::Job.count })
117127
end
118128

@@ -121,7 +131,7 @@ def tell
121131
fairy_tail = FairyTail.new
122132
expect {
123133
expect {
124-
fairy_tail.delay.tell
134+
fairy_tail.delay.tell('a', kwarg: 'b')
125135
}.not_to change { fairy_tail.happy_ending }
126136
}.to change { Delayed::Job.count }.by(1)
127137
end
@@ -131,7 +141,7 @@ def tell
131141
fairy_tail = FairyTail.new
132142
expect {
133143
expect {
134-
fairy_tail.delay.tell
144+
fairy_tail.delay.tell('a', kwarg: 'b')
135145
}.not_to change { fairy_tail.happy_ending }
136146
}.to change { Delayed::Job.count }.by(1)
137147
end
@@ -141,8 +151,8 @@ def tell
141151
fairy_tail = FairyTail.new
142152
expect {
143153
expect {
144-
fairy_tail.delay.tell
145-
}.to change { fairy_tail.happy_ending }.from(nil).to(true)
154+
fairy_tail.delay.tell('a', kwarg: 'b')
155+
}.to change { fairy_tail.happy_ending }.from(nil).to %w(a b)
146156
}.not_to(change { Delayed::Job.count })
147157
end
148158
end

0 commit comments

Comments
 (0)