delayed 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,10 +70,12 @@ module Delayed
70
70
  def names=(names)
71
71
  raise "must include a name for priority >= 0" if names && !names.value?(0)
72
72
 
73
+ remove_named_priority_methods
73
74
  @ranges = nil
74
75
  @alerts = nil
75
76
  @names_to_priority = nil
76
77
  @names = names&.sort_by(&:last)&.to_h&.transform_values { |v| new(v) }
78
+ define_named_priority_methods
77
79
  end
78
80
 
79
81
  def alerts=(alerts)
@@ -124,17 +126,35 @@ module Delayed
124
126
  low + ((high - low).to_d / 2).ceil
125
127
  end
126
128
 
127
- def respond_to_missing?(method_name, include_private = false)
128
- names_to_priority.key?(method_name) || super
129
+ # Defines a class method (e.g. `Priority.eventual`) and an instance predicate (e.g.
130
+ # `priority.eventual?`) per name, as real, introspectable methods (visible to `methods`,
131
+ # RDoc, and Sorbet's tapioca, unlike `method_missing` dispatch). Pre-existing methods
132
+ # (e.g. a name of `superclass`) are never overridden.
133
+ def define_named_priority_methods
134
+ names.each_key do |name|
135
+ predicate = :"#{name}?"
136
+ define_singleton_method(name) { names_to_priority.fetch(name) } unless method_defined_on?(singleton_class, name)
137
+ define_method(predicate) { name.to_s == to_s } unless method_defined_on?(self, predicate)
138
+ end
129
139
  end
130
140
 
131
- def method_missing(method_name, *args)
132
- if names_to_priority.key?(method_name) && args.none?
133
- names_to_priority[method_name]
134
- else
135
- super
141
+ # Removes the methods previously defined for the current `names` (called before `names` is
142
+ # reassigned). The non-inherited (`false`) checks mean we only remove methods we defined
143
+ # ourselves, leaving any pre-existing method a colliding name never overrode (e.g.
144
+ # `Class#superclass`, `Numeric#zero?`) in place.
145
+ def remove_named_priority_methods
146
+ names.each_key do |name|
147
+ predicate = :"#{name}?"
148
+ singleton_class.send(:remove_method, name) if singleton_class.method_defined?(name, false)
149
+ remove_method(predicate) if method_defined?(predicate, false)
136
150
  end
137
151
  end
152
+
153
+ # Whether `mod` already responds to `name` (public, protected, private, or inherited), in
154
+ # which case a same-named priority must not override it.
155
+ def method_defined_on?(mod, name)
156
+ mod.method_defined?(name) || mod.private_method_defined?(name)
157
+ end
138
158
  end
139
159
 
140
160
  attr_reader :value
@@ -188,18 +208,6 @@ module Delayed
188
208
  to_i.to_d
189
209
  end
190
210
 
191
- private
192
-
193
- def respond_to_missing?(method_name, include_private = false)
194
- (method_name.to_s.end_with?('?') && self.class.names.key?(method_name.to_s[0..-2].to_sym)) || super
195
- end
196
-
197
- def method_missing(method_name, *args)
198
- if method_name.to_s.end_with?('?') && self.class.names.key?(method_name.to_s[0..-2].to_sym)
199
- method_name.to_s[0..-2] == to_s
200
- else
201
- super
202
- end
203
- end
211
+ send(:define_named_priority_methods)
204
212
  end
205
213
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- VERSION = '3.0.1'
4
+ VERSION = '4.0.0'
5
5
  end
data/lib/delayed.rb CHANGED
@@ -16,12 +16,14 @@ require 'delayed/backend/job_preparer'
16
16
  require 'delayed/helpers/migration'
17
17
  require 'delayed/worker'
18
18
  require 'delayed/job_wrapper'
19
+ require 'delayed/limitable'
19
20
 
20
21
  if defined?(Rails::Engine)
21
22
  require 'delayed/engine'
22
23
  else
23
24
  require 'active_record'
24
25
  require_relative '../app/models/delayed/job'
26
+ require_relative '../app/models/delayed/limit'
25
27
  end
26
28
 
27
29
  ActiveSupport.on_load(:active_job) do
@@ -29,6 +31,7 @@ ActiveSupport.on_load(:active_job) do
29
31
  ActiveJob::QueueAdapters::DelayedAdapter = Class.new(Delayed::ActiveJobAdapter)
30
32
 
31
33
  include Delayed::ActiveJobAdapter::EnqueuingPatch
34
+ include Delayed::Limitable
32
35
  end
33
36
 
34
37
  ActiveSupport.on_load(:action_mailer) do
@@ -113,10 +113,18 @@ RSpec.describe Delayed::ActiveJobAdapter do
113
113
  Delayed::Job.last.tap do |dj|
114
114
  dj.update!(handler: dj.handler.gsub('JobClass', 'MissingJobClass'))
115
115
  expect { dj.payload_object }.not_to raise_error
116
- expect { dj.payload_object.job_id }.to raise_error(NameError, 'uninitialized constant MissingJobClass')
116
+ if ActiveJob.gem_version >= Gem::Version.new('8.1')
117
+ expect { dj.payload_object.perform_now }.to raise_error(ActiveJob::UnknownJobClassError)
118
+ else
119
+ expect { dj.payload_object.job_id }.to raise_error(NameError, 'uninitialized constant MissingJobClass')
120
+ end
117
121
  end
118
122
  expect(Delayed::Worker.new.work_off).to eq([0, 1])
119
- expect(Delayed::Job.last.last_error).to match(/uninitialized constant MissingJobClass/)
123
+ if ActiveJob.gem_version >= Gem::Version.new('8.1')
124
+ expect(Delayed::Job.last.last_error).to match(/`MissingJobClass` doesn't exist/)
125
+ else
126
+ expect(Delayed::Job.last.last_error).to match(/uninitialized constant MissingJobClass/)
127
+ end
120
128
  end
121
129
 
122
130
  it 'deserializes even if an underlying argument gid is not defined' do
@@ -153,6 +161,12 @@ RSpec.describe Delayed::ActiveJobAdapter do
153
161
  expect(enqueued_delayed_jobs.last.priority).to eq(20)
154
162
  end
155
163
 
164
+ it 'ignores a nil priority, applying the default instead' do
165
+ JobClass.set(priority: nil).perform_later
166
+
167
+ expect(enqueued_delayed_jobs.last.priority).to eq(10)
168
+ end
169
+
156
170
  it 'raises an error when run_at is used' do
157
171
  expect { JobClass.set(run_at: arbitrary_time).perform_later }
158
172
  .to raise_error(/`:run_at` is not supported./)
@@ -196,6 +210,39 @@ RSpec.describe Delayed::ActiveJobAdapter do
196
210
  end
197
211
  end
198
212
 
213
+ context 'when using the ActiveJob test adapter' do
214
+ let(:queue_adapter) { :test }
215
+
216
+ it 'raises an error when run_at is used' do
217
+ expect { JobClass.set(run_at: arbitrary_time).perform_later }
218
+ .to raise_error(/`:run_at` is not supported./)
219
+ end
220
+
221
+ it 'supports priority as a Delayed::Priority' do
222
+ JobClass.set(priority: Delayed::Priority.eventual).perform_later
223
+
224
+ expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, 'priority' => 20)
225
+ end
226
+
227
+ it 'supports priority as a symbol' do
228
+ JobClass.set(priority: :eventual).perform_later
229
+
230
+ expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, 'priority' => 20)
231
+ end
232
+
233
+ it 'ignores a nil priority, applying the default instead' do
234
+ JobClass.set(priority: nil).perform_later
235
+
236
+ expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, 'priority' => nil)
237
+ end
238
+
239
+ it 'captures arbitrary provider attributes without interfering with enqueue' do
240
+ JobClass.set(foo: 'bar').perform_later
241
+
242
+ expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, queue: 'default')
243
+ end
244
+ end
245
+
199
246
  context 'when the ActiveJob performable defines a max_attempts' do
200
247
  let(:job_class) do
201
248
  Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
@@ -437,6 +484,263 @@ RSpec.describe Delayed::ActiveJobAdapter do
437
484
  end
438
485
  end
439
486
 
487
+ describe 'ActiveJob .retry_on' do
488
+ let(:retry_job_class) do
489
+ Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
490
+ retry_on(RetryTestError)
491
+ retry_on(RetryTestErrorWithSpecificPriority, priority: 123)
492
+
493
+ queue_with_priority 567
494
+
495
+ def perform(error_class_name)
496
+ raise error_class_name.constantize
497
+ end
498
+ end
499
+ end
500
+
501
+ before do
502
+ allow(Delayed::Job).to receive(:enqueue_job).and_call_original
503
+ allow(Delayed::Job).to receive(:enqueue_all).and_call_original
504
+
505
+ stub_const('RetryTestError', Class.new(StandardError))
506
+ stub_const('RetryTestErrorWithSpecificPriority', Class.new(StandardError))
507
+ stub_const('MyRetryJob', retry_job_class)
508
+ end
509
+
510
+ context 'when retry_on does not specify a priority' do
511
+ it 're-enqueues a new delayed job with the same priority' do
512
+ MyRetryJob.perform_later('RetryTestError')
513
+ original = Delayed::Job.last
514
+
515
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
516
+
517
+ retried = Delayed::Job.last
518
+ expect(retried.id).not_to eq(original.id)
519
+ expect(retried.priority).to eq(567)
520
+
521
+ expect(retried.payload_object.job_data['priority']).to be_an(Integer)
522
+ expect(retried.payload_object.job_data['priority']).to eq(567)
523
+ end
524
+
525
+ it 'reuses a priority and queue set for the specific job run' do
526
+ MyRetryJob.set(priority: 789, queue: 'fake_queue').perform_later('RetryTestError')
527
+
528
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
529
+
530
+ retried = Delayed::Job.last
531
+ expect(retried.priority).to eq(789)
532
+ expect(retried.queue).to eq('fake_queue')
533
+ end
534
+
535
+ it 're-enqueues with the current priority of the job row, in case it was updated after enqueue' do
536
+ MyRetryJob.perform_later('RetryTestError')
537
+ Delayed::Job.last.update!(priority: 5)
538
+
539
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
540
+
541
+ expect(Delayed::Job.last.priority).to eq(5)
542
+ end
543
+ end
544
+
545
+ context 'when retry_on specifies a priority' do
546
+ it 're-enqueues with the specified priority' do
547
+ MyRetryJob.perform_later('RetryTestErrorWithSpecificPriority')
548
+
549
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
550
+
551
+ expect(Delayed::Job.last.priority).to eq(123)
552
+ end
553
+ end
554
+
555
+ it 'records the error that triggered the retry on the re-enqueued job' do
556
+ MyRetryJob.perform_later('RetryTestError')
557
+
558
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
559
+
560
+ expect(Delayed::Job.last.last_error).to start_with('RetryTestError')
561
+ end
562
+
563
+ it 'preserves the original creation time on the re-enqueued job' do
564
+ Timecop.freeze(arbitrary_time) do
565
+ MyRetryJob.perform_later('RetryTestError')
566
+ end
567
+
568
+ Timecop.freeze(arbitrary_time + 2.hours) do
569
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
570
+ end
571
+
572
+ retried = Delayed::Job.last
573
+ expect(retried.created_at).to eq(arbitrary_time)
574
+ expect(retried.run_at).to be_within(1.second).of(arbitrary_time + 2.hours + 3.seconds)
575
+ end
576
+
577
+ context 'when attempts are exhausted' do
578
+ it 'marks the ActiveJob as terminated and fails the job immediately, halting the worker retry backstop' do
579
+ job = MyRetryJob.new('RetryTestError')
580
+ job.exception_executions = { '[RetryTestError]' => 4 } # retry_on defaults to 5 attempts
581
+ job.enqueue
582
+ original = Delayed::Job.last
583
+
584
+ expect(Delayed::Worker.new.work_off).to eq([0, 1])
585
+
586
+ expect(Delayed::Job.count).to eq(1)
587
+ Delayed::Job.last.tap do |dj|
588
+ expect(dj.id).to eq(original.id)
589
+ expect(dj.attempts).to eq(1)
590
+ expect(dj.failed_at).to be_present
591
+ expect(dj.last_error).to match(/RetryTestError/)
592
+ expect(dj.payload_object.job_data['terminated_at']).to be_present
593
+ expect(dj.payload_object.job_data['exception_executions']).to eq('[RetryTestError]' => 5)
594
+ end
595
+ end
596
+
597
+ it 'resets execution attempts if (and only if) the row attempts is set back to 0' do
598
+ MyRetryJob.new('RetryTestError').tap do |job|
599
+ job.executions = 4
600
+ job.exception_executions = { '[RetryTestError]' => 4 }
601
+ job.enqueue
602
+ end
603
+
604
+ expect(Delayed::Worker.new.work_off).to eq([0, 1])
605
+
606
+ Delayed::Job.last.tap do |dj|
607
+ expect(dj.failed_at).to be_present
608
+ expect(dj.payload_object.job_data['terminated_at']).to be_present
609
+ expect(dj.payload_object.job_data['executions']).to eq(4)
610
+ expect(dj.payload_object.job_data['exception_executions']).to eq('[RetryTestError]' => 5)
611
+ end
612
+
613
+ # without setting attempts back to 0:
614
+ Delayed::Job.last.update!(failed_at: nil, locked_at: nil, locked_by: nil)
615
+
616
+ expect(Delayed::Worker.new.work_off).to eq([0, 1])
617
+
618
+ retried = Delayed::Job.last
619
+ expect(retried.failed_at).to be_present
620
+ expect(retried.attempts).to eq(2)
621
+ expect(retried.payload_object.job_data['terminated_at']).to be_present
622
+ expect(retried.payload_object.job_data['executions']).to eq(4)
623
+ expect(retried.payload_object.job_data['exception_executions']).to eq('[RetryTestError]' => 6)
624
+
625
+ # also setting attempts back to 0:
626
+ Delayed::Job.last.update!(failed_at: nil, attempts: 0, locked_at: nil, locked_by: nil)
627
+
628
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
629
+
630
+ retried = Delayed::Job.last
631
+ expect(retried.failed_at).to be_nil
632
+ expect(retried.attempts).to eq(0)
633
+ expect(retried.payload_object.job_data['terminated_at']).to be_nil
634
+ expect(retried.payload_object.job_data['executions']).to eq(1)
635
+ expect(retried.payload_object.job_data['exception_executions']).to eq('[RetryTestError]' => 1)
636
+ end
637
+ end
638
+
639
+ context 'when an error is not covered by any retry_on declaration' do
640
+ before do
641
+ stub_const('UncoveredTestError', Class.new(StandardError))
642
+ end
643
+
644
+ it 'marks the ActiveJob as terminated but leaves the job to be retried by the worker itself' do
645
+ MyRetryJob.perform_later('UncoveredTestError')
646
+
647
+ expect(Delayed::Worker.new.work_off).to eq([0, 1])
648
+
649
+ expect(Delayed::Job.count).to eq(1)
650
+ Delayed::Job.last.tap do |dj|
651
+ expect(dj.attempts).to eq(1)
652
+ expect(dj.failed_at).to be_nil
653
+ expect(dj.last_error).to match(/UncoveredTestError/)
654
+ expect(dj.payload_object.job_data['terminated_at']).to be_present
655
+ end
656
+ end
657
+ end
658
+
659
+ if ActiveJob.gem_version.release >= Gem::Version.new('7.1')
660
+ context 'when attempts is :unlimited' do
661
+ let(:retry_job_class) do
662
+ Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
663
+ retry_on(RetryTestError, attempts: :unlimited)
664
+
665
+ queue_with_priority 567
666
+
667
+ def perform(error_class_name)
668
+ raise error_class_name.constantize
669
+ end
670
+ end
671
+ end
672
+
673
+ it 're-enqueues the job even when executions far exceed the default attempt limit' do
674
+ job = MyRetryJob.new('RetryTestError')
675
+ job.exception_executions = { '[RetryTestError]' => 10_000 }
676
+ job.enqueue
677
+
678
+ expect(Delayed::Worker.new.work_off).to eq([1, 0])
679
+
680
+ retried = Delayed::Job.last
681
+ expect(retried.payload_object.job_data['exception_executions']).to eq('[RetryTestError]' => 10_001)
682
+ end
683
+ end
684
+ end
685
+
686
+ context 'when using the ActiveJob test adapter' do
687
+ let(:queue_adapter) { :test }
688
+
689
+ it 're-enqueues with the priority specified by retry_on' do
690
+ ActiveJob::Base.execute(MyRetryJob.new('RetryTestErrorWithSpecificPriority').serialize)
691
+
692
+ expect(MyRetryJob.queue_adapter.enqueued_jobs.first).to include(job: MyRetryJob, 'priority' => 123)
693
+ end
694
+
695
+ it 're-enqueues with the original priority when retry_on does not specify one' do
696
+ ActiveJob::Base.execute(MyRetryJob.new('RetryTestError').serialize)
697
+
698
+ expect(MyRetryJob.queue_adapter.enqueued_jobs.first).to include(job: MyRetryJob, 'priority' => 567)
699
+ end
700
+ end
701
+ end
702
+
703
+ describe 'legacy job hooks' do
704
+ let(:job_class) do
705
+ Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
706
+ cattr_accessor(:messages) { [] }
707
+
708
+ def perform
709
+ self.class.messages << 'perform'
710
+ end
711
+
712
+ def before(_delayed_job)
713
+ self.class.messages << 'before'
714
+ end
715
+ end
716
+ end
717
+
718
+ it 'invokes hooks defined on the job class, with a deprecation warning' do
719
+ JobClass.perform_later
720
+ delayed_job = enqueued_delayed_jobs.last
721
+
722
+ expect { delayed_job.invoke_job }
723
+ .to output(/\[DEPRECATION\] Job hook methods .* are deprecated\. Use ActiveJob callbacks instead\./).to_stderr
724
+
725
+ expect(JobClass.messages).to eq(%w(before perform))
726
+ end
727
+
728
+ context 'when the job class does not define any hook methods' do
729
+ let(:job_class) do
730
+ Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
731
+ def perform; end
732
+ end
733
+ end
734
+
735
+ it 'does not emit a deprecation warning' do
736
+ JobClass.perform_later
737
+ delayed_job = enqueued_delayed_jobs.last
738
+
739
+ expect { delayed_job.invoke_job }.not_to output.to_stderr
740
+ end
741
+ end
742
+ end
743
+
440
744
  describe '.enqueue_all' do # rubocop:disable Metrics/BlockLength
441
745
  let(:adapter) { ActiveJob::Base.queue_adapter }
442
746
 
@@ -199,6 +199,29 @@ describe Delayed::Job do
199
199
  end
200
200
  end
201
201
 
202
+ context 'when payload is an ActiveJob wrapper built from previously-serialized job data' do
203
+ let(:arbitrary_time) { Time.parse('2021-01-05 03:34:33 UTC') }
204
+
205
+ it "preserves the wrapped job's enqueued_at as created_at" do
206
+ job_data = Timecop.freeze(arbitrary_time) { ActiveJobJob.new.serialize }
207
+
208
+ job = described_class.enqueue(Delayed::JobWrapper.new(job_data))
209
+
210
+ expect(job.reload.created_at).to eq(arbitrary_time)
211
+ end
212
+
213
+ if ActiveJob.gem_version.release >= Gem::Version.new('7.1')
214
+ it "preserves the wrapped job's scheduled_at as run_at" do
215
+ active_job = ActiveJobJob.new
216
+ active_job.scheduled_at = arbitrary_time + 1.hour
217
+
218
+ job = described_class.enqueue(Delayed::JobWrapper.new(active_job.serialize))
219
+
220
+ expect(job.run_at).to eq(arbitrary_time + 1.hour)
221
+ end
222
+ end
223
+ end
224
+
202
225
  context 'when payload is a bare ActiveJob::Base instance' do
203
226
  it 'raises' do
204
227
  expect { described_class.enqueue(payload_object: ActiveJobJob.new) }.to raise_error(RuntimeError, /Delayed::Job enqueue methods do not accept ActiveJobs/)
@@ -290,6 +313,26 @@ describe Delayed::Job do
290
313
  expect { described_class.enqueue_all(jobs) }.to raise_error(RuntimeError, /Delayed::Job enqueue methods do not accept ActiveJobs/)
291
314
  end
292
315
  end
316
+
317
+ it 'stamps created_at and updated_at' do
318
+ now = described_class.db_time_now
319
+ described_class.enqueue_all([build_job])
320
+
321
+ described_class.last.tap do |job|
322
+ expect(job.created_at).to be_within(1).of(now)
323
+ expect(job.updated_at).to be_within(1).of(now)
324
+ end
325
+ end
326
+
327
+ it 'preserves a pre-assigned created_at while still stamping updated_at' do
328
+ original_time = described_class.db_time_now - 3.hours
329
+ described_class.enqueue_all([build_job(SimpleJob.new, created_at: original_time)])
330
+
331
+ described_class.last.tap do |job|
332
+ expect(job.created_at).to be_within(1).of(original_time)
333
+ expect(job.updated_at).to be_within(1).of(described_class.db_time_now)
334
+ end
335
+ end
293
336
  end
294
337
 
295
338
  describe '#hook' do
@@ -306,11 +349,28 @@ describe Delayed::Job do
306
349
  job = described_class.new(payload_object: JobWithEnqueueHook.new)
307
350
  expect(job.payload_object).not_to receive(:enqueue)
308
351
  expect { job.hook(:enqueue) }
309
- .to raise_error(RuntimeError, ':enqueue hook is no longer supported')
352
+ .to raise_error(ArgumentError, 'Unknown hook: :enqueue')
310
353
  end
311
354
  end
312
355
  end
313
356
 
357
+ describe '#invoke_job' do
358
+ it "does not clobber a priority attribute on the object receiving a delayed message" do
359
+ stub_const('PrioritizedThing', Class.new do
360
+ attr_accessor :priority
361
+
362
+ def check_in; end
363
+ end)
364
+ thing = PrioritizedThing.new
365
+ thing.priority = :domain_specific_value
366
+
367
+ job = thing.delay(priority: 3).check_in
368
+ job.invoke_job
369
+
370
+ expect(thing.priority).to eq(:domain_specific_value)
371
+ end
372
+ end
373
+
314
374
  describe 'callbacks' do
315
375
  before(:each) do
316
376
  CallbackJob.messages = []
@@ -1023,7 +1083,7 @@ describe Delayed::Job do
1023
1083
  worker.work_off
1024
1084
  @job.reload
1025
1085
  expect(@job.last_error).to match(/did not work/)
1026
- expect(@job.last_error).to match(/sample_jobs.rb:\d+:in `perform'/)
1086
+ expect(@job.last_error).to match(/sample_jobs.rb:\d+:in (`|'ErrorJob#)perform'/)
1027
1087
  expect(@job.attempts).to eq(1)
1028
1088
  expect(@job.run_at).to be > described_class.db_time_now - 10.minutes
1029
1089
  expect(@job.run_at).to be < described_class.db_time_now + 10.minutes