delayed 3.1.0 → 4.0.1

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.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Delayed
4
+ # A mixin that wraps a class's `perform` method (or other methods named via
5
+ # `on:`) in `Delayed::Limit.within_limit`. It is automatically included in
6
+ # ActiveJob classes, and configures the job to retry with a polynomial
7
+ # backoff when the limit's `wait_timeout` would be exceeded:
8
+ #
9
+ # class TouchesThirdPartyApiJob < ApplicationJob
10
+ # with_limit :third_party_api, max: 100, per: 1.minute
11
+ #
12
+ # def perform
13
+ # # ...
14
+ # end
15
+ # end
16
+ #
17
+ # The 'purpose' defaults to the job's underscored class name, and the limit
18
+ # is registered via `Delayed::Limit.register!` (unless the purpose was
19
+ # already registered, e.g. in an initializer, in which case the `max:`/`per:`
20
+ # config may be omitted entirely). Multiple job classes may share a purpose
21
+ # (and its limit) as long as their configs match exactly.
22
+ #
23
+ # Use `on:` to wrap one or more other instance methods instead of `perform`,
24
+ # e.g. if only a portion of the job's work is subject to the limit:
25
+ #
26
+ # with_limit :third_party_api, max: 100, per: 1.minute, on: :deliver!
27
+ #
28
+ # A class may declare `with_limit` more than once (e.g. to apply different
29
+ # limits to different methods), but only the first declaration defines the
30
+ # job's retry behavior (`attempts`, `wait`, and `jitter`, with `wait_timeout`
31
+ # acting as a floor on the computed wait). If two declarations' wait timeouts
32
+ # differ meaningfully, declare the one with the longer `wait_timeout` first.
33
+ #
34
+ # Retries rely on ActiveJob's `retry_on`, so when this mixin is included in
35
+ # a plain (non-ActiveJob) class, the named methods are still wrapped in
36
+ # `within_limit`, but the class must define its own rescue/retry behavior
37
+ # for `Delayed::Limit::LimitExceededError`.
38
+ module Limitable
39
+ extend ActiveSupport::Concern
40
+
41
+ DEFAULT_RETRY_ATTEMPTS = if defined?(ActiveJob) && ActiveJob.gem_version >= Gem::Version.new('7.0')
42
+ :unlimited
43
+ else
44
+ Float::INFINITY
45
+ end
46
+
47
+ class_methods do
48
+ def with_limit(
49
+ purpose = name.underscore.to_sym,
50
+ on: :perform,
51
+ max: nil,
52
+ per: nil,
53
+ wait_timeout: 5.seconds,
54
+ retry_jitter: 0.1,
55
+ retry_attempts: DEFAULT_RETRY_ATTEMPTS,
56
+ retry_wait: ->(attempt) { polynomial_backoff(wait_timeout, attempt, retry_jitter) }
57
+ )
58
+ Delayed::Limit.register!(purpose, max: max, per: per) if max || per
59
+
60
+ if defined?(ActiveJob::Base) && self < ActiveJob::Base &&
61
+ rescue_handlers.none? { |klass, _| klass == Delayed::Limit::LimitExceededError.name }
62
+ retry_on(Delayed::Limit::LimitExceededError, attempts: retry_attempts, wait: retry_wait)
63
+ end
64
+
65
+ prepend(Module.new do
66
+ Array(on).each do |method_name|
67
+ define_method(method_name) do |*args, **kwargs, &block|
68
+ Delayed::Limit.within_limit(purpose, wait_timeout: wait_timeout) do
69
+ super(*args, **kwargs, &block)
70
+ end
71
+ end
72
+ end
73
+ end)
74
+ end
75
+
76
+ private
77
+
78
+ def polynomial_backoff(min_wait, attempt, jitter)
79
+ [min_wait, (attempt**4)].max * (1 + rand(-jitter..jitter))
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Delayed
4
- VERSION = '3.1.0'
4
+ VERSION = '4.0.1'
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