delayed 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +560 -0
- data/Rakefile +35 -0
- data/lib/delayed.rb +72 -0
- data/lib/delayed/active_job_adapter.rb +65 -0
- data/lib/delayed/backend/base.rb +166 -0
- data/lib/delayed/backend/job_preparer.rb +43 -0
- data/lib/delayed/exceptions.rb +14 -0
- data/lib/delayed/job.rb +250 -0
- data/lib/delayed/lifecycle.rb +85 -0
- data/lib/delayed/message_sending.rb +65 -0
- data/lib/delayed/monitor.rb +134 -0
- data/lib/delayed/performable_mailer.rb +22 -0
- data/lib/delayed/performable_method.rb +47 -0
- data/lib/delayed/plugin.rb +15 -0
- data/lib/delayed/plugins/connection.rb +13 -0
- data/lib/delayed/plugins/instrumentation.rb +39 -0
- data/lib/delayed/priority.rb +164 -0
- data/lib/delayed/psych_ext.rb +135 -0
- data/lib/delayed/railtie.rb +7 -0
- data/lib/delayed/runnable.rb +46 -0
- data/lib/delayed/serialization/active_record.rb +18 -0
- data/lib/delayed/syck_ext.rb +42 -0
- data/lib/delayed/tasks.rb +40 -0
- data/lib/delayed/worker.rb +233 -0
- data/lib/delayed/yaml_ext.rb +10 -0
- data/lib/delayed_job.rb +1 -0
- data/lib/delayed_job_active_record.rb +1 -0
- data/lib/generators/delayed/generator.rb +7 -0
- data/lib/generators/delayed/migration_generator.rb +28 -0
- data/lib/generators/delayed/next_migration_version.rb +14 -0
- data/lib/generators/delayed/templates/migration.rb +22 -0
- data/spec/autoloaded/clazz.rb +6 -0
- data/spec/autoloaded/instance_clazz.rb +5 -0
- data/spec/autoloaded/instance_struct.rb +6 -0
- data/spec/autoloaded/struct.rb +7 -0
- data/spec/database.yml +25 -0
- data/spec/delayed/active_job_adapter_spec.rb +267 -0
- data/spec/delayed/job_spec.rb +953 -0
- data/spec/delayed/monitor_spec.rb +276 -0
- data/spec/delayed/plugins/instrumentation_spec.rb +49 -0
- data/spec/delayed/priority_spec.rb +154 -0
- data/spec/delayed/serialization/active_record_spec.rb +15 -0
- data/spec/delayed/tasks_spec.rb +116 -0
- data/spec/helper.rb +196 -0
- data/spec/lifecycle_spec.rb +77 -0
- data/spec/message_sending_spec.rb +149 -0
- data/spec/performable_mailer_spec.rb +68 -0
- data/spec/performable_method_spec.rb +123 -0
- data/spec/psych_ext_spec.rb +94 -0
- data/spec/sample_jobs.rb +117 -0
- data/spec/worker_spec.rb +235 -0
- data/spec/yaml_ext_spec.rb +48 -0
- metadata +326 -0
@@ -0,0 +1,10 @@
|
|
1
|
+
# These extensions allow properly serializing and autoloading of
|
2
|
+
# Classes, Modules and Structs
|
3
|
+
|
4
|
+
require 'yaml'
|
5
|
+
if /syck|yecht/i.match?(YAML.parser.class.name)
|
6
|
+
require File.expand_path('syck_ext', __dir__)
|
7
|
+
require File.expand_path('serialization/active_record', __dir__)
|
8
|
+
else
|
9
|
+
require File.expand_path('psych_ext', __dir__)
|
10
|
+
end
|
data/lib/delayed_job.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'delayed'
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'delayed'
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require "generators/delayed/generator"
|
2
|
+
require "generators/delayed/next_migration_version"
|
3
|
+
require "rails/generators/migration"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
# Extend the DelayedJobGenerator so that it creates an AR migration
|
7
|
+
module Delayed
|
8
|
+
class MigrationGenerator < Generator
|
9
|
+
include Rails::Generators::Migration
|
10
|
+
extend NextMigrationVersion
|
11
|
+
|
12
|
+
source_paths << File.join(File.dirname(__FILE__), "templates")
|
13
|
+
|
14
|
+
def create_migration_file
|
15
|
+
migration_template "migration.rb", "db/migrate/create_delayed_jobs.rb", migration_version: migration_version
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.next_migration_number(dirname)
|
19
|
+
ActiveRecord::Generators::Base.next_migration_number dirname
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def migration_version
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" if ActiveRecord::VERSION::MAJOR >= 5
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Delayed
|
2
|
+
module NextMigrationVersion
|
3
|
+
# while methods have moved around this has been the implementation
|
4
|
+
# since ActiveRecord 3.0
|
5
|
+
def next_migration_number(dirname)
|
6
|
+
next_migration_number = current_migration_number(dirname) + 1
|
7
|
+
if ActiveRecord::Base.timestamped_migrations
|
8
|
+
[Time.now.utc.strftime("%Y%m%d%H%M%S"), format("%.14d", next_migration_number)].max
|
9
|
+
else
|
10
|
+
format("%.3d", next_migration_number)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class CreateDelayedJobs < ActiveRecord::Migration<%= migration_version %>
|
2
|
+
def self.up
|
3
|
+
create_table :delayed_jobs do |table|
|
4
|
+
table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue
|
5
|
+
table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually.
|
6
|
+
table.text :handler, null: false # YAML-encoded string of the object that will do work
|
7
|
+
table.text :last_error # reason for last failure (See Note below)
|
8
|
+
table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future.
|
9
|
+
table.datetime :locked_at # Set when a client is working on this object
|
10
|
+
table.datetime :failed_at # Set when all retries have failed
|
11
|
+
table.string :locked_by # Who is working on this object (if locked)
|
12
|
+
table.string :queue # The name of the queue this job is in
|
13
|
+
table.timestamps null: true
|
14
|
+
end
|
15
|
+
|
16
|
+
add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority"
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.down
|
20
|
+
drop_table :delayed_jobs
|
21
|
+
end
|
22
|
+
end
|
data/spec/database.yml
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
mysql:
|
2
|
+
adapter: mysql
|
3
|
+
host: 127.0.0.1
|
4
|
+
database: delayed_job_test
|
5
|
+
username: root
|
6
|
+
port: 3306
|
7
|
+
encoding: utf8
|
8
|
+
|
9
|
+
mysql2:
|
10
|
+
adapter: mysql2
|
11
|
+
host: 127.0.0.1
|
12
|
+
database: delayed_job_test
|
13
|
+
username: root
|
14
|
+
port: 3306
|
15
|
+
encoding: utf8
|
16
|
+
|
17
|
+
postgresql:
|
18
|
+
adapter: postgresql
|
19
|
+
host: 127.0.0.1
|
20
|
+
url: <%= ENV.fetch('DATABASE_URL', 'postgresql://127.0.0.1/delayed_job_test') %>
|
21
|
+
password: postgres
|
22
|
+
|
23
|
+
sqlite3:
|
24
|
+
adapter: sqlite3
|
25
|
+
database: "tmp/database.sqlite"
|
@@ -0,0 +1,267 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
RSpec.describe Delayed::ActiveJobAdapter do
|
4
|
+
let(:arbitrary_time) do
|
5
|
+
Time.parse('2021-01-05 03:34:33 UTC')
|
6
|
+
end
|
7
|
+
let(:queue_adapter) { :delayed }
|
8
|
+
let(:job_class) do
|
9
|
+
Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
|
10
|
+
def perform; end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
before do
|
15
|
+
stub_const 'JobClass', job_class
|
16
|
+
end
|
17
|
+
|
18
|
+
around do |example|
|
19
|
+
adapter_was = ActiveJob::Base.queue_adapter
|
20
|
+
ActiveJob::Base.queue_adapter = queue_adapter
|
21
|
+
example.run
|
22
|
+
ensure
|
23
|
+
ActiveJob::Base.queue_adapter = adapter_was
|
24
|
+
end
|
25
|
+
|
26
|
+
describe '.set' do
|
27
|
+
it 'supports priority as an integer' do
|
28
|
+
JobClass.set(priority: 43).perform_later
|
29
|
+
|
30
|
+
expect(Delayed::Job.last.priority).to be_reporting
|
31
|
+
expect(Delayed::Job.last.priority).to eq(43)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'supports priority as a Delayed::Priority' do
|
35
|
+
JobClass.set(priority: Delayed::Priority.eventual).perform_later
|
36
|
+
|
37
|
+
expect(Delayed::Job.last.priority).to be_eventual
|
38
|
+
expect(Delayed::Job.last.priority).to eq(20)
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'supports priority as a symbol' do
|
42
|
+
JobClass.set(priority: :eventual).perform_later
|
43
|
+
|
44
|
+
expect(Delayed::Job.last.priority).to be_eventual
|
45
|
+
expect(Delayed::Job.last.priority).to eq(20)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'raises an error when run_at is used' do
|
49
|
+
expect { JobClass.set(run_at: arbitrary_time).perform_later }
|
50
|
+
.to raise_error(/`:run_at` is not supported./)
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'converts wait_until to run_at' do
|
54
|
+
JobClass.set(wait_until: arbitrary_time).perform_later
|
55
|
+
|
56
|
+
expect(Delayed::Job.last.run_at).to eq('2021-01-05 03:34:33 UTC')
|
57
|
+
end
|
58
|
+
|
59
|
+
context 'when running at a specific time' do
|
60
|
+
around do |example|
|
61
|
+
Timecop.freeze(arbitrary_time) { example.run }
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'adds wait input to current time' do
|
65
|
+
JobClass.set(wait: (1.day + 1.hour + 1.minute)).perform_later
|
66
|
+
|
67
|
+
expect(Delayed::Job.last.run_at).to eq('2021-01-06 04:35:33 UTC')
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
context 'when the Delayed::Job class supports arbitrary attributes' do
|
72
|
+
before do
|
73
|
+
Delayed::Job.class_eval do
|
74
|
+
def foo=(value)
|
75
|
+
self.queue = "foo-#{value}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
after do
|
81
|
+
Delayed::Job.undef_method(:foo=)
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'calls the expected setter' do
|
85
|
+
JobClass.set(foo: 'bar').perform_later
|
86
|
+
|
87
|
+
expect(Delayed::Job.last.queue).to eq('foo-bar')
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
context 'when the ActiveJob performable defines a max_attempts' do
|
92
|
+
let(:job_class) do
|
93
|
+
Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
|
94
|
+
def perform; end
|
95
|
+
|
96
|
+
def max_attempts
|
97
|
+
3
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'surfaces max_attempts on the JobWrapper' do
|
103
|
+
JobClass.perform_later
|
104
|
+
|
105
|
+
expect(Delayed::Job.last.max_attempts).to eq 3
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context 'when the ActiveJob performable defines an arbitrary method' do
|
110
|
+
let(:job_class) do
|
111
|
+
Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
|
112
|
+
def perform; end
|
113
|
+
|
114
|
+
def arbitrary_method
|
115
|
+
'hello'
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
it 'surfaces arbitrary_method on the JobWrapper' do
|
121
|
+
JobClass.perform_later
|
122
|
+
|
123
|
+
expect(Delayed::Job.last.payload_object.arbitrary_method).to eq 'hello'
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
describe '.perform_later' do
|
129
|
+
it 'applies the default ActiveJob queue and priority' do
|
130
|
+
JobClass.perform_later
|
131
|
+
|
132
|
+
expect(Delayed::Job.last.queue).to eq('default')
|
133
|
+
expect(Delayed::Job.last.priority).to eq(10)
|
134
|
+
end
|
135
|
+
|
136
|
+
it 'supports overriding queue and priority' do
|
137
|
+
JobClass.set(queue: 'a', priority: 3).perform_later
|
138
|
+
|
139
|
+
expect(Delayed::Job.last.queue).to eq('a')
|
140
|
+
expect(Delayed::Job.last.priority).to eq(3)
|
141
|
+
end
|
142
|
+
|
143
|
+
context 'when all default queues and priorities are nil' do
|
144
|
+
before do
|
145
|
+
ActiveJob::Base.queue_name = nil
|
146
|
+
ActiveJob::Base.priority = nil
|
147
|
+
Delayed::Worker.default_queue_name = nil
|
148
|
+
Delayed::Worker.default_priority = nil
|
149
|
+
end
|
150
|
+
|
151
|
+
it 'applies no queue or priority' do
|
152
|
+
JobClass.perform_later
|
153
|
+
|
154
|
+
expect(Delayed::Job.last.queue).to be_nil
|
155
|
+
expect(Delayed::Job.last.priority).to eq(0)
|
156
|
+
end
|
157
|
+
|
158
|
+
it 'supports overriding queue and priority' do
|
159
|
+
JobClass.set(queue: 'a', priority: 3).perform_later
|
160
|
+
|
161
|
+
expect(Delayed::Job.last.queue).to eq('a')
|
162
|
+
expect(Delayed::Job.last.priority).to eq(3)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
context 'when there is a default Delayed queue and priority, but not ActiveJob' do
|
167
|
+
before do
|
168
|
+
ActiveJob::Base.queue_name = nil
|
169
|
+
ActiveJob::Base.priority = nil
|
170
|
+
Delayed::Worker.default_queue_name = 'dj_default'
|
171
|
+
Delayed::Worker.default_priority = 99
|
172
|
+
end
|
173
|
+
|
174
|
+
it 'applies the default Delayed queue and priority' do
|
175
|
+
JobClass.perform_later
|
176
|
+
|
177
|
+
expect(Delayed::Job.last.queue).to eq('dj_default')
|
178
|
+
expect(Delayed::Job.last.priority).to eq(99)
|
179
|
+
end
|
180
|
+
|
181
|
+
it 'supports overriding queue and priority' do
|
182
|
+
JobClass.set(queue: 'a', priority: 3).perform_later
|
183
|
+
|
184
|
+
expect(Delayed::Job.last.queue).to eq('a')
|
185
|
+
expect(Delayed::Job.last.priority).to eq(3)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
context 'when ActiveJob specifies a different default queue and priority' do
|
190
|
+
before do
|
191
|
+
ActiveJob::Base.queue_name = 'aj_default'
|
192
|
+
ActiveJob::Base.priority = 11
|
193
|
+
end
|
194
|
+
|
195
|
+
it 'applies the default ActiveJob queue and priority' do
|
196
|
+
JobClass.perform_later
|
197
|
+
|
198
|
+
expect(Delayed::Job.last.queue).to eq('aj_default')
|
199
|
+
expect(Delayed::Job.last.priority).to eq(11)
|
200
|
+
end
|
201
|
+
|
202
|
+
it 'supports overriding queue and priority' do
|
203
|
+
JobClass.set(queue: 'a', priority: 3).perform_later
|
204
|
+
|
205
|
+
expect(Delayed::Job.last.queue).to eq('a')
|
206
|
+
expect(Delayed::Job.last.priority).to eq(3)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
context 'when ActiveJob uses queue_with_priority' do
|
211
|
+
let(:job_class) do
|
212
|
+
Class.new(ActiveJob::Base) do # rubocop:disable Rails/ApplicationJob
|
213
|
+
queue_with_priority Delayed::Priority.reporting
|
214
|
+
|
215
|
+
def perform; end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
it 'applies the specified priority' do
|
220
|
+
JobClass.perform_later
|
221
|
+
|
222
|
+
expect(Delayed::Job.last.priority).to eq(30)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
context 'when using the ActiveJob test adapter' do
|
227
|
+
let(:queue_adapter) { :test }
|
228
|
+
|
229
|
+
it 'applies the default ActiveJob queue and priority' do
|
230
|
+
JobClass.perform_later
|
231
|
+
|
232
|
+
if ActiveJob.gem_version < Gem::Version.new('6')
|
233
|
+
expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, queue: 'default')
|
234
|
+
else
|
235
|
+
expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, 'priority' => nil, queue: 'default')
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
context 'when ActiveJob specifies a different default queue and priority' do
|
240
|
+
before do
|
241
|
+
ActiveJob::Base.queue_name = 'aj_default'
|
242
|
+
ActiveJob::Base.priority = 11
|
243
|
+
end
|
244
|
+
|
245
|
+
it 'applies the default ActiveJob queue and priority' do
|
246
|
+
JobClass.perform_later
|
247
|
+
|
248
|
+
if ActiveJob.gem_version < Gem::Version.new('6')
|
249
|
+
expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, queue: 'aj_default')
|
250
|
+
else
|
251
|
+
expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, 'priority' => 11, queue: 'aj_default')
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
it 'supports overriding queue, priority, and wait_until' do
|
257
|
+
JobClass.set(queue: 'a', priority: 3, wait_until: arbitrary_time).perform_later
|
258
|
+
|
259
|
+
if ActiveJob.gem_version < Gem::Version.new('6')
|
260
|
+
expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, queue: 'a', at: arbitrary_time.to_f)
|
261
|
+
else
|
262
|
+
expect(JobClass.queue_adapter.enqueued_jobs.first).to include(job: JobClass, 'priority' => 3, queue: 'a', at: arbitrary_time.to_f)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
@@ -0,0 +1,953 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe Delayed::Job do
|
4
|
+
let(:worker) { Delayed::Worker.new }
|
5
|
+
|
6
|
+
def create_job(opts = {})
|
7
|
+
described_class.create(opts.merge(payload_object: SimpleJob.new))
|
8
|
+
end
|
9
|
+
|
10
|
+
before do
|
11
|
+
Delayed::Worker.max_priority = nil
|
12
|
+
Delayed::Worker.min_priority = nil
|
13
|
+
Delayed::Worker.max_claims = 1 # disable multithreading because SimpleJob is not threadsafe
|
14
|
+
Delayed::Worker.default_priority = 99
|
15
|
+
Delayed::Worker.delay_jobs = true
|
16
|
+
Delayed::Worker.default_queue_name = 'default_tracking'
|
17
|
+
SimpleJob.runs = 0
|
18
|
+
described_class.delete_all
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'sets run_at automatically if not set' do
|
22
|
+
expect(described_class.create(payload_object: ErrorJob.new).run_at).not_to be_nil
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'does not set run_at automatically if already set' do
|
26
|
+
later = described_class.db_time_now + 5.minutes
|
27
|
+
job = described_class.create(payload_object: ErrorJob.new, run_at: later)
|
28
|
+
expect(job.run_at).to be_within(1).of(later)
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '#reload' do
|
32
|
+
it 'reloads the payload' do
|
33
|
+
job = described_class.enqueue payload_object: SimpleJob.new
|
34
|
+
expect(job.payload_object.object_id).not_to eq(job.reload.payload_object.object_id)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe 'enqueue' do
|
39
|
+
it "allows enqueue hook to modify job at DB level" do
|
40
|
+
later = described_class.db_time_now + 20.minutes
|
41
|
+
job = described_class.enqueue payload_object: EnqueueJobMod.new
|
42
|
+
expect(described_class.find(job.id).run_at).to be_within(1).of(later)
|
43
|
+
end
|
44
|
+
|
45
|
+
context 'with a hash' do
|
46
|
+
it "raises ArgumentError when handler doesn't respond_to :perform" do
|
47
|
+
expect { described_class.enqueue(payload_object: Object.new) }.to raise_error(ArgumentError)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'is able to set priority' do
|
51
|
+
job = described_class.enqueue payload_object: SimpleJob.new, priority: 5
|
52
|
+
expect(job.priority).to eq(5)
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'is able to set priority by symbol name' do
|
56
|
+
job = described_class.enqueue SimpleJob.new, priority: :eventual
|
57
|
+
expect(job.priority).to be_eventual
|
58
|
+
expect(job.priority).to eq(20)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'uses default priority' do
|
62
|
+
job = described_class.enqueue payload_object: SimpleJob.new
|
63
|
+
expect(job.priority).to eq(99)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'is able to set run_at' do
|
67
|
+
later = described_class.db_time_now + 5.minutes
|
68
|
+
job = described_class.enqueue payload_object: SimpleJob.new, run_at: later
|
69
|
+
expect(job.run_at).to be_within(1).of(later)
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'is able to set queue' do
|
73
|
+
job = described_class.enqueue payload_object: NamedQueueJob.new, queue: 'tracking'
|
74
|
+
expect(job.queue).to eq('tracking')
|
75
|
+
end
|
76
|
+
|
77
|
+
it 'uses default queue' do
|
78
|
+
job = described_class.enqueue payload_object: SimpleJob.new
|
79
|
+
expect(job.queue).to eq(Delayed::Worker.default_queue_name)
|
80
|
+
end
|
81
|
+
|
82
|
+
it "uses the payload object's queue" do
|
83
|
+
job = described_class.enqueue payload_object: NamedQueueJob.new
|
84
|
+
expect(job.queue).to eq(NamedQueueJob.new.queue_name)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context 'with multiple arguments' do
|
89
|
+
it "raises ArgumentError when handler doesn't respond_to :perform" do
|
90
|
+
expect { described_class.enqueue(Object.new) }.to raise_error(ArgumentError)
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'increases count after enqueuing items' do
|
94
|
+
described_class.enqueue SimpleJob.new
|
95
|
+
expect(described_class.count).to eq(1)
|
96
|
+
end
|
97
|
+
|
98
|
+
it 'uses default priority when it is not set' do
|
99
|
+
@job = described_class.enqueue SimpleJob.new
|
100
|
+
expect(@job.priority).to eq(99)
|
101
|
+
end
|
102
|
+
|
103
|
+
it 'works with jobs in modules' do
|
104
|
+
M::ModuleJob.runs = 0
|
105
|
+
job = described_class.enqueue M::ModuleJob.new
|
106
|
+
expect { job.invoke_job }.to change { M::ModuleJob.runs }.from(0).to(1)
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'does not mutate the options hash' do
|
110
|
+
options = { priority: 1 }
|
111
|
+
described_class.enqueue SimpleJob.new, options
|
112
|
+
expect(options).to eq(priority: 1)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
context 'with delay_jobs = false' do
|
117
|
+
before(:each) do
|
118
|
+
Delayed::Worker.delay_jobs = false
|
119
|
+
end
|
120
|
+
|
121
|
+
it 'does not increase count after enqueuing items' do
|
122
|
+
described_class.enqueue SimpleJob.new
|
123
|
+
expect(described_class.count).to eq(0)
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'invokes the enqueued job' do
|
127
|
+
job = SimpleJob.new
|
128
|
+
expect(job).to receive(:perform)
|
129
|
+
described_class.enqueue job
|
130
|
+
end
|
131
|
+
|
132
|
+
it 'returns a job, not the result of invocation' do
|
133
|
+
expect(described_class.enqueue(SimpleJob.new)).to be_instance_of(described_class)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
describe 'callbacks' do
|
139
|
+
before(:each) do
|
140
|
+
CallbackJob.messages = []
|
141
|
+
end
|
142
|
+
|
143
|
+
%w(before success after).each do |callback|
|
144
|
+
it "calls #{callback} with job" do
|
145
|
+
job = described_class.enqueue(CallbackJob.new)
|
146
|
+
expect(job.payload_object).to receive(callback).with(job)
|
147
|
+
job.invoke_job
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
it 'calls before and after callbacks' do
|
152
|
+
job = described_class.enqueue(CallbackJob.new)
|
153
|
+
expect(CallbackJob.messages).to eq(['enqueue'])
|
154
|
+
job.invoke_job
|
155
|
+
expect(CallbackJob.messages).to eq(%w(enqueue before perform success after))
|
156
|
+
end
|
157
|
+
|
158
|
+
it 'calls the after callback with an error' do
|
159
|
+
job = described_class.enqueue(CallbackJob.new)
|
160
|
+
expect(job.payload_object).to receive(:perform).and_raise(RuntimeError.new('fail'))
|
161
|
+
|
162
|
+
expect { job.invoke_job }.to raise_error(RuntimeError, 'fail')
|
163
|
+
expect(CallbackJob.messages).to eq(['enqueue', 'before', 'error: RuntimeError', 'after'])
|
164
|
+
end
|
165
|
+
|
166
|
+
it 'calls error when before raises an error' do
|
167
|
+
job = described_class.enqueue(CallbackJob.new)
|
168
|
+
expect(job.payload_object).to receive(:before).and_raise(RuntimeError.new('fail'))
|
169
|
+
expect { job.invoke_job }.to raise_error(RuntimeError, 'fail')
|
170
|
+
expect(CallbackJob.messages).to eq(['enqueue', 'error: RuntimeError', 'after'])
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
describe 'payload_object' do
|
175
|
+
it 'raises a DeserializationError when the job class is totally unknown' do
|
176
|
+
job = described_class.new handler: '--- !ruby/object:JobThatDoesNotExist {}'
|
177
|
+
expect { job.payload_object }.to raise_error(Delayed::DeserializationError)
|
178
|
+
end
|
179
|
+
|
180
|
+
it 'raises a DeserializationError when the job struct is totally unknown' do
|
181
|
+
job = described_class.new handler: '--- !ruby/struct:StructThatDoesNotExist {}'
|
182
|
+
expect { job.payload_object }.to raise_error(Delayed::DeserializationError)
|
183
|
+
end
|
184
|
+
|
185
|
+
it 'raises a DeserializationError when the YAML.load raises argument error' do
|
186
|
+
job = described_class.new handler: '--- !ruby/struct:GoingToRaiseArgError {}'
|
187
|
+
expect(YAML).to receive(:load_dj).and_raise(ArgumentError)
|
188
|
+
expect { job.payload_object }.to raise_error(Delayed::DeserializationError)
|
189
|
+
end
|
190
|
+
|
191
|
+
it 'raises a DeserializationError when the YAML.load raises syntax error' do
|
192
|
+
# only test with Psych since the other YAML parsers don't raise a SyntaxError
|
193
|
+
unless /syck|yecht/i.match?(YAML.parser.class.name)
|
194
|
+
job = described_class.new handler: 'message: "no ending quote'
|
195
|
+
expect { job.payload_object }.to raise_error(Delayed::DeserializationError)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
describe 'reserve' do
|
201
|
+
before do
|
202
|
+
Delayed::Worker.max_run_time = 2.minutes
|
203
|
+
end
|
204
|
+
|
205
|
+
after do
|
206
|
+
Time.zone = nil
|
207
|
+
end
|
208
|
+
|
209
|
+
it 'does not reserve failed jobs' do
|
210
|
+
create_job attempts: 50, failed_at: described_class.db_time_now
|
211
|
+
expect(described_class.reserve(worker)).to eq []
|
212
|
+
end
|
213
|
+
|
214
|
+
it 'does not reserve jobs scheduled for the future' do
|
215
|
+
create_job run_at: described_class.db_time_now + 1.minute
|
216
|
+
expect(described_class.reserve(worker)).to eq []
|
217
|
+
end
|
218
|
+
|
219
|
+
it 'reserves jobs scheduled for the past' do
|
220
|
+
job = create_job run_at: described_class.db_time_now - 1.minute
|
221
|
+
expect(described_class.reserve(worker)).to eq([job])
|
222
|
+
end
|
223
|
+
|
224
|
+
it 'reserves jobs scheduled for the past when time zones are involved' do
|
225
|
+
Time.zone = 'US/Eastern'
|
226
|
+
job = create_job run_at: described_class.db_time_now - 1.minute
|
227
|
+
expect(described_class.reserve(worker)).to eq([job])
|
228
|
+
end
|
229
|
+
|
230
|
+
it 'does not reserve jobs locked by other workers' do
|
231
|
+
job = create_job
|
232
|
+
other_worker = Delayed::Worker.new
|
233
|
+
other_worker.name = 'other_worker'
|
234
|
+
expect(described_class.reserve(other_worker)).to eq([job])
|
235
|
+
expect(described_class.reserve(worker)).to eq []
|
236
|
+
end
|
237
|
+
|
238
|
+
it 'reserves open jobs' do
|
239
|
+
job = create_job
|
240
|
+
expect(described_class.reserve(worker)).to eq([job])
|
241
|
+
end
|
242
|
+
|
243
|
+
it 'reserves expired jobs' do
|
244
|
+
job = create_job(locked_by: 'some other worker',
|
245
|
+
locked_at: described_class.db_time_now - Delayed::Worker.max_run_time - 1.minute)
|
246
|
+
expect(described_class.reserve(worker)).to eq([job])
|
247
|
+
end
|
248
|
+
|
249
|
+
it 'reserves own jobs' do
|
250
|
+
job = create_job(locked_by: worker.name, locked_at: (described_class.db_time_now - 1.minute))
|
251
|
+
expect(described_class.reserve(worker)).to eq([job])
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
describe '#name' do
|
256
|
+
it 'is the class name of the job that was enqueued' do
|
257
|
+
expect(described_class.create(payload_object: ErrorJob.new).name).to eq('ErrorJob')
|
258
|
+
end
|
259
|
+
|
260
|
+
it 'is the class name of the performable job if it is an ActiveJob' do
|
261
|
+
job_wrapper = ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper.new(ActiveJobJob.new.serialize)
|
262
|
+
expect(described_class.create(payload_object: job_wrapper).name).to eq('ActiveJobJob')
|
263
|
+
end
|
264
|
+
|
265
|
+
it 'is the method that will be called if its a performable method object' do
|
266
|
+
job = described_class.new(payload_object: NamedJob.new)
|
267
|
+
expect(job.name).to eq('named_job')
|
268
|
+
end
|
269
|
+
|
270
|
+
it 'is the instance method that will be called if its a performable method object' do
|
271
|
+
job = Story.create(text: '...').delay.save
|
272
|
+
expect(job.name).to eq('Story#save')
|
273
|
+
end
|
274
|
+
|
275
|
+
it 'parses from handler on deserialization error' do
|
276
|
+
job = Story.create(text: '...').delay.text
|
277
|
+
job.payload_object.object.destroy
|
278
|
+
expect(job.reload.name).to eq('Delayed::PerformableMethod')
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
context 'worker prioritization' do
|
283
|
+
after do
|
284
|
+
Delayed::Worker.max_claims = nil
|
285
|
+
Delayed::Worker.max_priority = nil
|
286
|
+
Delayed::Worker.min_priority = nil
|
287
|
+
Delayed::Worker.read_ahead = nil
|
288
|
+
end
|
289
|
+
|
290
|
+
it 'fetches jobs ordered by priority' do
|
291
|
+
10.times { described_class.enqueue SimpleJob.new, priority: rand(10) }
|
292
|
+
Delayed::Worker.read_ahead = 10
|
293
|
+
Delayed::Worker.max_claims = 10
|
294
|
+
jobs = described_class.reserve(worker)
|
295
|
+
expect(jobs.size).to eq(10)
|
296
|
+
jobs.each_cons(2) do |a, b|
|
297
|
+
expect(a.priority).to be <= b.priority
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
it 'only finds jobs greater than or equal to min priority' do
|
302
|
+
min = 5
|
303
|
+
Delayed::Worker.min_priority = min
|
304
|
+
Delayed::Worker.max_claims = 2
|
305
|
+
[4, 5, 6].sort_by { |_i| rand }.each { |i| create_job priority: i }
|
306
|
+
jobs = described_class.reserve(worker)
|
307
|
+
expect(jobs.map(&:priority).min).to be >= min
|
308
|
+
jobs.map(&:destroy)
|
309
|
+
expect(described_class.reserve(worker)).to eq []
|
310
|
+
end
|
311
|
+
|
312
|
+
it 'only finds jobs less than or equal to max priority' do
|
313
|
+
max = 5
|
314
|
+
Delayed::Worker.max_priority = max
|
315
|
+
Delayed::Worker.max_claims = 2
|
316
|
+
[4, 5, 6].sort_by { |_i| rand }.each { |i| create_job priority: i }
|
317
|
+
jobs = described_class.reserve(worker)
|
318
|
+
expect(jobs.map(&:priority).max).to be <= max
|
319
|
+
jobs.map(&:destroy)
|
320
|
+
expect(described_class.reserve(worker)).to eq []
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
context 'clear_locks!' do
|
325
|
+
before do
|
326
|
+
@job = create_job(locked_by: 'worker1', locked_at: described_class.db_time_now)
|
327
|
+
end
|
328
|
+
|
329
|
+
it 'clears locks for the given worker' do
|
330
|
+
described_class.clear_locks!('worker1')
|
331
|
+
expect(described_class.reserve(worker)).to eq([@job])
|
332
|
+
end
|
333
|
+
|
334
|
+
it 'does not clear locks for other workers' do
|
335
|
+
described_class.clear_locks!('different_worker')
|
336
|
+
expect(described_class.reserve(worker)).not_to include(@job)
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
context 'unlock' do
|
341
|
+
before do
|
342
|
+
@job = create_job(locked_by: 'worker', locked_at: described_class.db_time_now)
|
343
|
+
end
|
344
|
+
|
345
|
+
it 'clears locks' do
|
346
|
+
@job.unlock
|
347
|
+
expect(@job.locked_by).to be_nil
|
348
|
+
expect(@job.locked_at).to be_nil
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
context 'large handler' do
|
353
|
+
before do
|
354
|
+
text = 'Lorem ipsum dolor sit amet. ' * 1000
|
355
|
+
@job = described_class.enqueue Delayed::PerformableMethod.new(text, :length, {})
|
356
|
+
end
|
357
|
+
|
358
|
+
it 'has an id' do
|
359
|
+
expect(@job.id).not_to be_nil
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
context 'named queues' do
|
364
|
+
context 'when worker has one queue set' do
|
365
|
+
before do
|
366
|
+
Delayed::Worker.queues = ['large']
|
367
|
+
end
|
368
|
+
|
369
|
+
it 'only works off jobs which are from its queue' do
|
370
|
+
expect(SimpleJob.runs).to eq(0)
|
371
|
+
|
372
|
+
create_job(queue: 'large')
|
373
|
+
create_job(queue: 'small')
|
374
|
+
worker.work_off
|
375
|
+
|
376
|
+
expect(SimpleJob.runs).to eq(1)
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
context 'when worker has two queue set' do
|
381
|
+
before do
|
382
|
+
Delayed::Worker.queues = %w(large small)
|
383
|
+
end
|
384
|
+
|
385
|
+
it 'only works off jobs which are from its queue' do
|
386
|
+
expect(SimpleJob.runs).to eq(0)
|
387
|
+
|
388
|
+
create_job(queue: 'large')
|
389
|
+
create_job(queue: 'small')
|
390
|
+
create_job(queue: 'medium')
|
391
|
+
create_job
|
392
|
+
worker.work_off
|
393
|
+
|
394
|
+
expect(SimpleJob.runs).to eq(2)
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
context 'when worker does not have queue set' do
|
399
|
+
before(:each) do
|
400
|
+
Delayed::Worker.queues = []
|
401
|
+
end
|
402
|
+
|
403
|
+
it 'works off all jobs' do
|
404
|
+
expect(SimpleJob.runs).to eq(0)
|
405
|
+
|
406
|
+
create_job(queue: 'one')
|
407
|
+
create_job(queue: 'two')
|
408
|
+
create_job
|
409
|
+
worker.work_off
|
410
|
+
|
411
|
+
expect(SimpleJob.runs).to eq(3)
|
412
|
+
end
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
416
|
+
context 'max_attempts' do
|
417
|
+
before(:each) do
|
418
|
+
@job = described_class.enqueue SimpleJob.new
|
419
|
+
end
|
420
|
+
|
421
|
+
it 'is not defined' do
|
422
|
+
expect(@job.max_attempts).to be_nil
|
423
|
+
end
|
424
|
+
|
425
|
+
it 'uses the max_attempts value on the payload when defined' do
|
426
|
+
expect(@job.payload_object).to receive(:max_attempts).and_return(99)
|
427
|
+
expect(@job.max_attempts).to eq(99)
|
428
|
+
end
|
429
|
+
end
|
430
|
+
|
431
|
+
describe '#max_run_time' do
|
432
|
+
before(:each) { @job = described_class.enqueue SimpleJob.new }
|
433
|
+
|
434
|
+
it 'is not defined' do
|
435
|
+
expect(@job.max_run_time).to be_nil
|
436
|
+
end
|
437
|
+
|
438
|
+
it 'results in a default run time when not defined' do
|
439
|
+
expect(worker.max_run_time(@job)).to eq(20.minutes)
|
440
|
+
end
|
441
|
+
|
442
|
+
it 'uses the max_run_time value on the payload when defined' do
|
443
|
+
expect(@job.payload_object).to receive(:max_run_time).and_return(10.minutes)
|
444
|
+
expect(@job.max_run_time).to eq(10.minutes)
|
445
|
+
end
|
446
|
+
|
447
|
+
it 'results in an overridden run time when defined' do
|
448
|
+
expect(@job.payload_object).to receive(:max_run_time).and_return(15.minutes)
|
449
|
+
expect(worker.max_run_time(@job)).to eq(15.minutes)
|
450
|
+
end
|
451
|
+
|
452
|
+
it 'job set max_run_time can not exceed default max run time' do
|
453
|
+
expect(@job.payload_object).to receive(:max_run_time).and_return(20.minutes + 60)
|
454
|
+
expect(worker.max_run_time(@job)).to eq(20.minutes)
|
455
|
+
end
|
456
|
+
end
|
457
|
+
|
458
|
+
describe 'destroy_failed_jobs' do
|
459
|
+
context 'with a SimpleJob' do
|
460
|
+
before(:each) do
|
461
|
+
@job = described_class.enqueue SimpleJob.new
|
462
|
+
end
|
463
|
+
|
464
|
+
it 'is not defined' do
|
465
|
+
expect(@job.destroy_failed_jobs?).to be false
|
466
|
+
end
|
467
|
+
|
468
|
+
it 'uses the destroy failed jobs value on the payload when defined' do
|
469
|
+
expect(@job.payload_object).to receive(:destroy_failed_jobs?).and_return(true)
|
470
|
+
expect(@job.destroy_failed_jobs?).to be true
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
context 'with a job that raises DserializationError' do
|
475
|
+
before(:each) do
|
476
|
+
@job = described_class.new handler: '--- !ruby/struct:GoingToRaiseArgError {}'
|
477
|
+
end
|
478
|
+
|
479
|
+
it 'falls back reasonably' do
|
480
|
+
expect(YAML).to receive(:load_dj).and_raise(ArgumentError)
|
481
|
+
expect(@job.destroy_failed_jobs?).to be false
|
482
|
+
end
|
483
|
+
end
|
484
|
+
end
|
485
|
+
|
486
|
+
describe 'yaml serialization' do
|
487
|
+
context 'when serializing jobs' do
|
488
|
+
it 'raises error ArgumentError for new records' do
|
489
|
+
story = Story.new(text: 'hello')
|
490
|
+
if story.respond_to?(:new_record?)
|
491
|
+
expect { story.delay.tell }.to raise_error(
|
492
|
+
ArgumentError,
|
493
|
+
"job cannot be created for non-persisted record: #{story.inspect}",
|
494
|
+
)
|
495
|
+
end
|
496
|
+
end
|
497
|
+
|
498
|
+
it 'raises error ArgumentError for destroyed records' do
|
499
|
+
story = Story.create(text: 'hello')
|
500
|
+
story.destroy
|
501
|
+
expect { story.delay.tell }.to raise_error(
|
502
|
+
ArgumentError,
|
503
|
+
"job cannot be created for non-persisted record: #{story.inspect}",
|
504
|
+
)
|
505
|
+
end
|
506
|
+
end
|
507
|
+
|
508
|
+
context 'when reload jobs back' do
|
509
|
+
it 'reloads changed attributes' do
|
510
|
+
story = Story.create(text: 'hello')
|
511
|
+
job = story.delay.tell
|
512
|
+
story.text = 'goodbye'
|
513
|
+
story.save!
|
514
|
+
expect(job.reload.payload_object.object.text).to eq('goodbye')
|
515
|
+
end
|
516
|
+
|
517
|
+
it 'raises deserialization error for destroyed records' do
|
518
|
+
story = Story.create(text: 'hello')
|
519
|
+
job = story.delay.tell
|
520
|
+
story.destroy
|
521
|
+
expect { job.reload.payload_object }.to raise_error(Delayed::DeserializationError)
|
522
|
+
end
|
523
|
+
end
|
524
|
+
end
|
525
|
+
|
526
|
+
describe 'worker integration' do
|
527
|
+
before do
|
528
|
+
described_class.delete_all
|
529
|
+
SimpleJob.runs = 0
|
530
|
+
end
|
531
|
+
|
532
|
+
describe 'running a job' do
|
533
|
+
it 'fails after Worker.max_run_time' do
|
534
|
+
Delayed::Worker.max_run_time = 1.second
|
535
|
+
job = described_class.create payload_object: LongRunningJob.new
|
536
|
+
worker.run(job)
|
537
|
+
expect(job.error).not_to be_nil
|
538
|
+
expect(job.reload.last_error).to match(/expired/)
|
539
|
+
expect(job.reload.last_error).to match(/Delayed::Worker\.max_run_time is only 1 second/)
|
540
|
+
expect(job.attempts).to eq(1)
|
541
|
+
end
|
542
|
+
|
543
|
+
context 'when the job raises a deserialization error' do
|
544
|
+
it 'marks the job as failed' do
|
545
|
+
job = described_class.create! handler: '--- !ruby/object:JobThatDoesNotExist {}'
|
546
|
+
expect_any_instance_of(described_class).to receive(:destroy_failed_jobs?).and_return(false)
|
547
|
+
worker.work_off
|
548
|
+
job.reload
|
549
|
+
expect(job).to be_failed
|
550
|
+
end
|
551
|
+
end
|
552
|
+
end
|
553
|
+
|
554
|
+
describe 'failed jobs' do
|
555
|
+
before do
|
556
|
+
@job = described_class.enqueue(ErrorJob.new, run_at: described_class.db_time_now - 1)
|
557
|
+
end
|
558
|
+
|
559
|
+
it 'records last_error when destroy_failed_jobs = false, max_attempts = 1' do
|
560
|
+
Delayed::Worker.max_attempts = 1
|
561
|
+
worker.run(@job)
|
562
|
+
@job.reload
|
563
|
+
expect(@job.error).not_to be_nil
|
564
|
+
expect(@job.last_error).to match(/did not work/)
|
565
|
+
expect(@job.attempts).to eq(1)
|
566
|
+
expect(@job).to be_failed
|
567
|
+
end
|
568
|
+
|
569
|
+
it 're-schedules jobs after failing' do
|
570
|
+
worker.work_off
|
571
|
+
@job.reload
|
572
|
+
expect(@job.last_error).to match(/did not work/)
|
573
|
+
expect(@job.last_error).to match(/sample_jobs.rb:\d+:in `perform'/)
|
574
|
+
expect(@job.attempts).to eq(1)
|
575
|
+
expect(@job.run_at).to be > described_class.db_time_now - 10.minutes
|
576
|
+
expect(@job.run_at).to be < described_class.db_time_now + 10.minutes
|
577
|
+
expect(@job.locked_by).to be_nil
|
578
|
+
expect(@job.locked_at).to be_nil
|
579
|
+
end
|
580
|
+
|
581
|
+
it 're-schedules jobs with handler provided time if present' do
|
582
|
+
job = described_class.enqueue(CustomRescheduleJob.new(99.minutes))
|
583
|
+
worker.run(job)
|
584
|
+
job.reload
|
585
|
+
|
586
|
+
expect((described_class.db_time_now + 99.minutes - job.run_at).abs).to be < 1
|
587
|
+
end
|
588
|
+
|
589
|
+
it "does not fail when the triggered error doesn't have a message" do
|
590
|
+
error_with_nil_message = StandardError.new
|
591
|
+
expect(error_with_nil_message).to receive(:message).twice.and_return(nil)
|
592
|
+
expect(@job).to receive(:invoke_job).and_raise error_with_nil_message
|
593
|
+
expect { worker.run(@job) }.not_to raise_error
|
594
|
+
end
|
595
|
+
end
|
596
|
+
|
597
|
+
context 'reschedule' do
|
598
|
+
before do
|
599
|
+
@job = described_class.create payload_object: SimpleJob.new
|
600
|
+
end
|
601
|
+
|
602
|
+
shared_examples_for 'any failure more than Worker.max_attempts times' do
|
603
|
+
context "when the job's payload has a #failure hook" do
|
604
|
+
before do
|
605
|
+
@job = described_class.create payload_object: OnPermanentFailureJob.new
|
606
|
+
expect(@job.payload_object).to respond_to(:failure)
|
607
|
+
end
|
608
|
+
|
609
|
+
it 'runs that hook' do
|
610
|
+
expect(@job.payload_object).to receive(:failure)
|
611
|
+
worker.reschedule(@job)
|
612
|
+
end
|
613
|
+
|
614
|
+
it 'handles error in hook' do
|
615
|
+
Delayed::Worker.destroy_failed_jobs = false
|
616
|
+
@job.payload_object.raise_error = true
|
617
|
+
expect { worker.reschedule(@job) }.not_to raise_error
|
618
|
+
expect(@job.failed_at).not_to be_nil
|
619
|
+
end
|
620
|
+
end
|
621
|
+
|
622
|
+
context "when the job's payload has no #failure hook" do
|
623
|
+
# It's a little tricky to test this in a straightforward way,
|
624
|
+
# because putting a not_to receive expectation on
|
625
|
+
# @job.payload_object.failure makes that object incorrectly return
|
626
|
+
# true to payload_object.respond_to? :failure, which is what
|
627
|
+
# reschedule uses to decide whether to call failure. So instead, we
|
628
|
+
# just make sure that the payload_object as it already stands doesn't
|
629
|
+
# respond_to? failure, then shove it through the iterated reschedule
|
630
|
+
# loop and make sure we don't get a NoMethodError (caused by calling
|
631
|
+
# that nonexistent failure method).
|
632
|
+
|
633
|
+
before do
|
634
|
+
expect(@job.payload_object).not_to respond_to(:failure)
|
635
|
+
end
|
636
|
+
|
637
|
+
it 'does not try to run that hook' do
|
638
|
+
expect {
|
639
|
+
Delayed::Worker.max_attempts.times { worker.reschedule(@job) }
|
640
|
+
}.not_to raise_exception
|
641
|
+
end
|
642
|
+
end
|
643
|
+
end
|
644
|
+
|
645
|
+
context 'and we want to destroy jobs' do
|
646
|
+
before do
|
647
|
+
Delayed::Worker.destroy_failed_jobs = true
|
648
|
+
end
|
649
|
+
|
650
|
+
it_behaves_like 'any failure more than Worker.max_attempts times'
|
651
|
+
|
652
|
+
it 'is destroyed if it failed more than Worker.max_attempts times' do
|
653
|
+
expect(@job).to receive(:destroy)
|
654
|
+
Delayed::Worker.max_attempts.times { worker.reschedule(@job) }
|
655
|
+
end
|
656
|
+
|
657
|
+
it 'is destroyed if the job has destroy failed jobs set' do
|
658
|
+
Delayed::Worker.destroy_failed_jobs = false
|
659
|
+
expect(@job).to receive(:destroy_failed_jobs?).and_return(true)
|
660
|
+
expect(@job).to receive(:destroy)
|
661
|
+
Delayed::Worker.max_attempts.times { worker.reschedule(@job) }
|
662
|
+
end
|
663
|
+
|
664
|
+
it 'is not destroyed if failed fewer than Worker.max_attempts times' do
|
665
|
+
expect(@job).not_to receive(:destroy)
|
666
|
+
(Delayed::Worker.max_attempts - 1).times { worker.reschedule(@job) }
|
667
|
+
end
|
668
|
+
end
|
669
|
+
|
670
|
+
context "and we don't want to destroy jobs" do
|
671
|
+
it_behaves_like 'any failure more than Worker.max_attempts times'
|
672
|
+
|
673
|
+
context 'and destroy failed jobs is false' do
|
674
|
+
it 'is failed if it failed more than Worker.max_attempts times' do
|
675
|
+
expect(@job.reload).not_to be_failed
|
676
|
+
Delayed::Worker.max_attempts.times { worker.reschedule(@job) }
|
677
|
+
expect(@job.reload).to be_failed
|
678
|
+
end
|
679
|
+
|
680
|
+
it 'is not failed if it failed fewer than Worker.max_attempts times' do
|
681
|
+
(Delayed::Worker.max_attempts - 1).times { worker.reschedule(@job) }
|
682
|
+
expect(@job.reload).not_to be_failed
|
683
|
+
end
|
684
|
+
end
|
685
|
+
|
686
|
+
context 'and destroy failed jobs for job is false' do
|
687
|
+
before do
|
688
|
+
Delayed::Worker.destroy_failed_jobs = true
|
689
|
+
end
|
690
|
+
|
691
|
+
it 'is failed if it failed more than Worker.max_attempts times' do
|
692
|
+
expect(@job).to receive(:destroy_failed_jobs?).and_return(false)
|
693
|
+
expect(@job.reload).not_to be_failed
|
694
|
+
Delayed::Worker.max_attempts.times { worker.reschedule(@job) }
|
695
|
+
expect(@job.reload).to be_failed
|
696
|
+
end
|
697
|
+
|
698
|
+
it 'is not failed if it failed fewer than Worker.max_attempts times' do
|
699
|
+
(Delayed::Worker.max_attempts - 1).times { worker.reschedule(@job) }
|
700
|
+
expect(@job.reload).not_to be_failed
|
701
|
+
end
|
702
|
+
end
|
703
|
+
end
|
704
|
+
end
|
705
|
+
end
|
706
|
+
|
707
|
+
describe "reserve_with_scope" do
|
708
|
+
let(:relation_class) { described_class.limit(1).class }
|
709
|
+
let(:worker) { instance_double(Delayed::Worker, name: "worker01", read_ahead: 1, max_claims: 1) }
|
710
|
+
let(:scope) do
|
711
|
+
instance_double(relation_class, update_all: nil, limit: [job]).tap do |s|
|
712
|
+
allow(s).to receive(:where).and_return(s)
|
713
|
+
end
|
714
|
+
end
|
715
|
+
let(:job) { instance_double(described_class, id: 1, assign_attributes: true, changes_applied: true) }
|
716
|
+
|
717
|
+
before do
|
718
|
+
allow(described_class.connection).to receive(:adapter_name).at_least(:once).and_return(dbms)
|
719
|
+
end
|
720
|
+
|
721
|
+
context "for mysql adapters" do
|
722
|
+
let(:dbms) { "MySQL" }
|
723
|
+
|
724
|
+
it "uses the optimized sql version" do
|
725
|
+
allow(described_class).to receive(:reserve_with_scope_using_default_sql)
|
726
|
+
described_class.reserve_with_scope(scope, worker, Time.current)
|
727
|
+
expect(described_class).not_to have_received(:reserve_with_scope_using_default_sql)
|
728
|
+
end
|
729
|
+
end
|
730
|
+
|
731
|
+
context "for a dbms without a specific implementation" do
|
732
|
+
let(:dbms) { "OtherDB" }
|
733
|
+
|
734
|
+
it "uses the plain sql version" do
|
735
|
+
allow(described_class).to receive(:reserve_with_scope_using_default_sql)
|
736
|
+
described_class.reserve_with_scope(scope, worker, Time.current)
|
737
|
+
expect(described_class).to have_received(:reserve_with_scope_using_default_sql).once
|
738
|
+
end
|
739
|
+
end
|
740
|
+
end
|
741
|
+
|
742
|
+
context "db_time_now" do
|
743
|
+
after do
|
744
|
+
Time.zone = nil
|
745
|
+
ActiveRecord::Base.default_timezone = :local
|
746
|
+
end
|
747
|
+
|
748
|
+
it "returns time in current time zone if set" do
|
749
|
+
Time.zone = "Arizona"
|
750
|
+
expect(described_class.db_time_now.zone).to eq("MST")
|
751
|
+
end
|
752
|
+
|
753
|
+
it "returns UTC time if that is the AR default" do
|
754
|
+
Time.zone = nil
|
755
|
+
ActiveRecord::Base.default_timezone = :utc
|
756
|
+
expect(described_class.db_time_now.zone).to eq "UTC"
|
757
|
+
end
|
758
|
+
|
759
|
+
it "returns local time if that is the AR default" do
|
760
|
+
Time.zone = "Arizona"
|
761
|
+
ActiveRecord::Base.default_timezone = :local
|
762
|
+
expect(described_class.db_time_now.zone).to eq("MST")
|
763
|
+
end
|
764
|
+
end
|
765
|
+
|
766
|
+
context "ActiveRecord::Base.table_name_prefix" do
|
767
|
+
it "when prefix is not set, use 'delayed_jobs' as table name" do
|
768
|
+
::ActiveRecord::Base.table_name_prefix = nil
|
769
|
+
described_class.set_delayed_job_table_name
|
770
|
+
|
771
|
+
expect(described_class.table_name).to eq "delayed_jobs"
|
772
|
+
end
|
773
|
+
|
774
|
+
it "when prefix is set, prepend it before default table name" do
|
775
|
+
::ActiveRecord::Base.table_name_prefix = "custom_"
|
776
|
+
described_class.set_delayed_job_table_name
|
777
|
+
|
778
|
+
expect(described_class.table_name).to eq "custom_delayed_jobs"
|
779
|
+
|
780
|
+
::ActiveRecord::Base.table_name_prefix = nil
|
781
|
+
described_class.set_delayed_job_table_name
|
782
|
+
end
|
783
|
+
end
|
784
|
+
|
785
|
+
describe '#age_alert?' do
|
786
|
+
let(:now) { described_class.db_time_now }
|
787
|
+
let(:run_at) { now - 1.minute }
|
788
|
+
let(:locked_at) { nil }
|
789
|
+
|
790
|
+
around do |example|
|
791
|
+
Delayed::Priority.names = { high: 0 }
|
792
|
+
Delayed::Priority.alerts = { high: { age: 5.minutes } }
|
793
|
+
Timecop.freeze(now) { example.run }
|
794
|
+
ensure
|
795
|
+
Delayed::Priority.names = nil
|
796
|
+
end
|
797
|
+
|
798
|
+
subject { described_class.enqueue(SimpleJob.new, run_at: run_at, locked_at: locked_at) }
|
799
|
+
|
800
|
+
it 'returns false' do
|
801
|
+
expect(subject.alert_age).to eq(5.minutes)
|
802
|
+
expect(subject.age).to be_within(1).of(1.minute)
|
803
|
+
expect(subject.age_alert?).to eq(false)
|
804
|
+
end
|
805
|
+
|
806
|
+
context 'when the job is older than specified alert age' do
|
807
|
+
let(:run_at) { now - 6.minutes }
|
808
|
+
|
809
|
+
it 'returns true' do
|
810
|
+
expect(subject.alert_age).to eq(5.minutes)
|
811
|
+
expect(subject.age).to be_within(1).of(6.minutes)
|
812
|
+
expect(subject.age_alert?).to eq(true)
|
813
|
+
end
|
814
|
+
end
|
815
|
+
|
816
|
+
context 'when the job has been running for a long time but was picked up quickly' do
|
817
|
+
let(:run_at) { now - 1.hour - 1.minute }
|
818
|
+
let(:locked_at) { now - 1.hour }
|
819
|
+
|
820
|
+
it 'returns false' do
|
821
|
+
expect(subject.alert_age).to eq 5.minutes
|
822
|
+
expect(subject.age).to be_within(1).of(1.minute)
|
823
|
+
expect(subject.age_alert?).to eq(false)
|
824
|
+
end
|
825
|
+
end
|
826
|
+
|
827
|
+
context 'when the job class defines an alert_age override' do
|
828
|
+
before do
|
829
|
+
stub_const('JobWithAlertAge', Struct.new(:perform) do
|
830
|
+
def alert_age
|
831
|
+
30.seconds
|
832
|
+
end
|
833
|
+
end)
|
834
|
+
end
|
835
|
+
|
836
|
+
subject { described_class.enqueue(JobWithAlertAge.new, run_at: run_at, locked_at: locked_at) }
|
837
|
+
|
838
|
+
it 'obeys the override' do
|
839
|
+
expect(subject.alert_age).to eq(30.seconds)
|
840
|
+
expect(subject.age).to be_within(1).of(1.minute)
|
841
|
+
expect(subject.age_alert?).to eq(true)
|
842
|
+
end
|
843
|
+
end
|
844
|
+
end
|
845
|
+
|
846
|
+
describe '#run_time_alert?' do
|
847
|
+
let(:now) { described_class.db_time_now }
|
848
|
+
let(:locked_at) { now - 1.minute }
|
849
|
+
|
850
|
+
around do |example|
|
851
|
+
Delayed::Priority.names = { high: 0 }
|
852
|
+
Delayed::Priority.alerts = { high: { run_time: 5.minutes } }
|
853
|
+
Timecop.freeze(now) { example.run }
|
854
|
+
ensure
|
855
|
+
Delayed::Priority.names = nil
|
856
|
+
end
|
857
|
+
|
858
|
+
subject { described_class.enqueue(SimpleJob.new, locked_at: locked_at) }
|
859
|
+
|
860
|
+
it 'returns false' do
|
861
|
+
expect(subject.alert_run_time).to eq(5.minutes)
|
862
|
+
expect(subject.run_time).to be_within(1).of(1.minute)
|
863
|
+
expect(subject.run_time_alert?).to eq(false)
|
864
|
+
end
|
865
|
+
|
866
|
+
context 'when the job is not locked (e.g. delay_jobs is false)' do
|
867
|
+
let(:locked_at) { nil }
|
868
|
+
|
869
|
+
it 'returns nil' do
|
870
|
+
expect(subject.alert_run_time).to eq(5.minutes)
|
871
|
+
expect(subject.run_time).to be_nil
|
872
|
+
expect(subject.run_time_alert?).to be_nil
|
873
|
+
end
|
874
|
+
end
|
875
|
+
|
876
|
+
context 'when the job has been running longer than specified alert run_time' do
|
877
|
+
let(:locked_at) { now - 6.minutes }
|
878
|
+
|
879
|
+
it 'returns true' do
|
880
|
+
expect(subject.alert_run_time).to eq(5.minutes)
|
881
|
+
expect(subject.run_time).to be_within(1).of(6.minutes)
|
882
|
+
expect(subject.run_time_alert?).to eq(true)
|
883
|
+
end
|
884
|
+
end
|
885
|
+
|
886
|
+
context 'when the job class defines an alert_run_time override' do
|
887
|
+
before do
|
888
|
+
stub_const('JobWithAlertRunTime', Struct.new(:perform) do
|
889
|
+
def alert_run_time
|
890
|
+
30.seconds
|
891
|
+
end
|
892
|
+
end)
|
893
|
+
end
|
894
|
+
|
895
|
+
subject { described_class.enqueue(JobWithAlertRunTime.new, locked_at: locked_at) }
|
896
|
+
|
897
|
+
it 'obeys the override' do
|
898
|
+
expect(subject.alert_run_time).to eq(30.seconds)
|
899
|
+
expect(subject.run_time).to be_within(1).of(1.minute)
|
900
|
+
expect(subject.run_time_alert?).to eq(true)
|
901
|
+
end
|
902
|
+
end
|
903
|
+
end
|
904
|
+
|
905
|
+
describe '#attempts_alert?' do
|
906
|
+
let(:now) { described_class.db_time_now }
|
907
|
+
let(:attempts) { 1 }
|
908
|
+
|
909
|
+
around do |example|
|
910
|
+
Delayed::Priority.names = { high: 0 }
|
911
|
+
Delayed::Priority.alerts = { high: { attempts: 5 } }
|
912
|
+
Timecop.freeze(now) { example.run }
|
913
|
+
ensure
|
914
|
+
Delayed::Priority.names = nil
|
915
|
+
end
|
916
|
+
|
917
|
+
subject { described_class.enqueue(SimpleJob.new, attempts: attempts) }
|
918
|
+
|
919
|
+
it 'returns false' do
|
920
|
+
expect(subject.alert_attempts).to eq 5
|
921
|
+
expect(subject.attempts).to eq 1
|
922
|
+
expect(subject.attempts_alert?).to eq false
|
923
|
+
end
|
924
|
+
|
925
|
+
context 'when the job reaches the specified alert attempts' do
|
926
|
+
let(:attempts) { 6 }
|
927
|
+
|
928
|
+
it 'returns true' do
|
929
|
+
expect(subject.alert_attempts).to eq 5
|
930
|
+
expect(subject.attempts).to eq 6
|
931
|
+
expect(subject.attempts_alert?).to eq true
|
932
|
+
end
|
933
|
+
end
|
934
|
+
|
935
|
+
context 'when the job class defines an alert_attempts override' do
|
936
|
+
before do
|
937
|
+
stub_const('JobWithAlertAttempts', Struct.new(:perform) do
|
938
|
+
def alert_attempts
|
939
|
+
1
|
940
|
+
end
|
941
|
+
end)
|
942
|
+
end
|
943
|
+
|
944
|
+
subject { described_class.enqueue(JobWithAlertAttempts.new, attempts: attempts) }
|
945
|
+
|
946
|
+
it 'obeys the override' do
|
947
|
+
expect(subject.alert_attempts).to eq 1
|
948
|
+
expect(subject.attempts).to eq 1
|
949
|
+
expect(subject.attempts_alert?).to eq true
|
950
|
+
end
|
951
|
+
end
|
952
|
+
end
|
953
|
+
end
|