backburner 1.7.0 → 1.8.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 +4 -4
- data/.github/workflows/ci.yml +45 -0
- data/.github/workflows/github-release.yml +58 -0
- data/.github/workflows/release.yml +221 -0
- data/CHANGELOG.md +29 -0
- data/Gemfile +4 -1
- data/HOOKS.md +23 -14
- data/README.md +32 -9
- data/backburner.gemspec +3 -0
- data/bin/backburner +1 -1
- data/lib/active_job/queue_adapters/backburner_adapter.rb +10 -3
- data/lib/backburner/configuration.rb +7 -0
- data/lib/backburner/connection.rb +4 -2
- data/lib/backburner/enqueue_pool.rb +88 -0
- data/lib/backburner/helpers.rb +77 -22
- data/lib/backburner/job.rb +46 -7
- data/lib/backburner/logger.rb +9 -0
- data/lib/backburner/performable.rb +1 -1
- data/lib/backburner/queue.rb +37 -19
- data/lib/backburner/version.rb +1 -1
- data/lib/backburner/worker.rb +112 -21
- data/lib/backburner/workers/forking.rb +4 -2
- data/lib/backburner/workers/simple.rb +5 -0
- data/lib/backburner/workers/threading.rb +1 -7
- data/lib/backburner/workers/threads_on_fork.rb +58 -23
- data/lib/backburner.rb +7 -1
- data/test/active_job_adapter_test.rb +24 -0
- data/test/back_burner_test.rb +13 -0
- data/test/connection_test.rb +22 -0
- data/test/fixtures/active_job_app.rb +53 -0
- data/test/fixtures/autoload/test_broken_name_job.rb +4 -0
- data/test/fixtures/autoload/test_broken_no_method_job.rb +4 -0
- data/test/fixtures/autoload/test_broken_require_job.rb +4 -0
- data/test/fixtures/autoload/test_broken_runtime_job.rb +4 -0
- data/test/fixtures/autoload/test_broken_syntax_job.rb +4 -0
- data/test/fixtures/autoload/test_broken_timeout_job.rb +4 -0
- data/test/fixtures/autoload/test_same_name_job.rb +5 -0
- data/test/fixtures/test_fork_jobs.rb +5 -0
- data/test/fixtures/test_jobs.rb +53 -0
- data/test/fixtures/test_queue_settings.rb +41 -1
- data/test/helpers/templogger.rb +1 -1
- data/test/helpers_test.rb +184 -0
- data/test/job_test.rb +22 -0
- data/test/logger_test.rb +27 -0
- data/test/performable_test.rb +50 -0
- data/test/queue_test.rb +63 -0
- data/test/test_helper.rb +57 -0
- data/test/worker_test.rb +346 -1
- data/test/workers/forking_worker_test.rb +19 -1
- data/test/workers/simple_worker_test.rb +272 -0
- data/test/workers/threading_worker_test.rb +22 -1
- data/test/workers/threads_on_fork_worker_test.rb +279 -12
- metadata +54 -6
- data/.travis.yml +0 -29
- data/circle.yml +0 -3
data/lib/backburner/worker.rb
CHANGED
|
@@ -9,6 +9,10 @@ module Backburner
|
|
|
9
9
|
include Backburner::Helpers
|
|
10
10
|
include Backburner::Logger
|
|
11
11
|
|
|
12
|
+
# The longest a worker that doesn't have a job's class waits to hand the job out again (its TTR
|
|
13
|
+
# otherwise), so a worker that has it picks it up soon after a rollout
|
|
14
|
+
MAX_CLASS_NOT_FOUND_DELAY = 60
|
|
15
|
+
|
|
12
16
|
# Backburner::Worker.known_queue_classes
|
|
13
17
|
# List of known_queue_classes
|
|
14
18
|
class << self
|
|
@@ -17,7 +21,8 @@ module Backburner
|
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
# Enqueues a job to be processed later by a worker.
|
|
20
|
-
# Options: `pri` (priority), `delay` (delay in secs), `ttr` (time to respond),
|
|
24
|
+
# Options: `pri` (priority), `delay` (delay in secs, rounded up to whole seconds), `ttr` (time to respond),
|
|
25
|
+
# `queue` (queue name)
|
|
21
26
|
#
|
|
22
27
|
# @raise [Beaneater::NotConnected] If beanstalk fails to connect.
|
|
23
28
|
# @example
|
|
@@ -25,7 +30,7 @@ module Backburner
|
|
|
25
30
|
#
|
|
26
31
|
def self.enqueue(job_class, args=[], opts={})
|
|
27
32
|
pri = resolve_priority(opts[:pri] || job_class)
|
|
28
|
-
delay =
|
|
33
|
+
delay = whole_seconds(opts[:delay])
|
|
29
34
|
ttr = resolve_respond_timeout(opts[:ttr] || job_class)
|
|
30
35
|
res = Backburner::Hooks.invoke_hook_events(job_class, :before_enqueue, *args)
|
|
31
36
|
|
|
@@ -34,18 +39,15 @@ module Backburner
|
|
|
34
39
|
data = { :class => job_class.name, :args => args }
|
|
35
40
|
queue = opts[:queue] && (Proc === opts[:queue] ? opts[:queue].call(job_class) : opts[:queue])
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
connection = Backburner::Connection.new(Backburner.configuration.beanstalk_url)
|
|
42
|
+
response = nil
|
|
43
|
+
Backburner::EnqueuePool.current.with_connection do |connection|
|
|
40
44
|
connection.retryable do
|
|
41
45
|
tube = connection.tubes[expand_tube_name(queue || job_class)]
|
|
42
46
|
serialized_data = Backburner.configuration.job_serializer_proc.call(data)
|
|
43
47
|
response = tube.put(serialized_data, :pri => pri, :delay => delay, :ttr => ttr)
|
|
44
48
|
end
|
|
45
|
-
return nil unless Backburner::Hooks.invoke_hook_events(job_class, :after_enqueue, *args)
|
|
46
|
-
ensure
|
|
47
|
-
connection.close if connection
|
|
48
49
|
end
|
|
50
|
+
return nil unless Backburner::Hooks.invoke_hook_events(job_class, :after_enqueue, *args)
|
|
49
51
|
|
|
50
52
|
response
|
|
51
53
|
end
|
|
@@ -149,17 +151,45 @@ module Backburner
|
|
|
149
151
|
|
|
150
152
|
# NB: There's a slight chance here that the connection to beanstalkd has
|
|
151
153
|
# gone down between the time we reserved / processed the job and here.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
154
|
+
class_missing = false
|
|
155
|
+
begin
|
|
156
|
+
job_class = begin
|
|
157
|
+
job.job_class
|
|
158
|
+
rescue Backburner::Job::JobNotFound # missing, or its file failed to load: see below
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
if job_class.nil? && job.job_class_missing?
|
|
162
|
+
# This worker doesn't have its class (renamed or removed, or a worker deployed before the code
|
|
163
|
+
# that enqueues it): give it back for a worker that has it rather than burying it, once per TTR
|
|
164
|
+
# (at most a minute) as beanstalkd would, without on_error or the failure hooks (as before) or
|
|
165
|
+
# using up its retries. A class whose file fails to load goes the normal failure path instead.
|
|
166
|
+
# ceiling: beanstalkd still counts the release, so once a worker has the class, a failure there
|
|
167
|
+
# gets fewer retries; counting these releases apart would need a marker in the job body.
|
|
168
|
+
class_missing = true
|
|
169
|
+
delay = [job.ttr || queue_config.respond_timeout, MAX_CLASS_NOT_FOUND_DELAY].min
|
|
170
|
+
job.task.release(:delay => delay)
|
|
171
|
+
self.log_warn "#{job.name}: class not found; released for #{delay}s for a worker that has it"
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
num_retries = job.stats.releases
|
|
175
|
+
max_job_retries = resolve_max_job_retries(job_class)
|
|
176
|
+
retry_status = "failed: attempt #{num_retries+1} of #{max_job_retries+1}"
|
|
177
|
+
if num_retries < max_job_retries # retry again
|
|
178
|
+
retry_delay = resolve_retry_delay(job_class)
|
|
179
|
+
delay = resolve_retry_delay_proc(job_class).call(retry_delay, num_retries) rescue retry_delay
|
|
180
|
+
delay = whole_seconds(delay)
|
|
181
|
+
job.retry(num_retries + 1, delay)
|
|
182
|
+
self.log_job_end(job.name, "#{retry_status}, retrying in #{delay}s") if job_started_at
|
|
183
|
+
else # retries failed, bury
|
|
184
|
+
job.bury
|
|
185
|
+
self.log_job_end(job.name, "#{retry_status}, burying") if job_started_at
|
|
186
|
+
end
|
|
187
|
+
rescue Beaneater::NotFoundError, Beaneater::NotConnected
|
|
188
|
+
# Its TTR ran out first, or the connection was lost: beanstalkd releases a reserved job when
|
|
189
|
+
# its TTR runs out or its client disconnects (unless the server restarted and lost it).
|
|
190
|
+
# (An error raised in a rescue clause isn't caught by the method's other rescues, hence this one.)
|
|
191
|
+
self.log_error "#{job.name} was no longer reserved (its TTR ran out, or the connection was lost), so it was neither retried nor buried"
|
|
192
|
+
return if class_missing # on_error isn't called for a class this worker lacks
|
|
163
193
|
end
|
|
164
194
|
|
|
165
195
|
handle_error(e, job.name, job.args, job)
|
|
@@ -181,11 +211,49 @@ module Backburner
|
|
|
181
211
|
# Returns a list of all tubes known within the system
|
|
182
212
|
# Filtered for tubes that match the known prefix
|
|
183
213
|
def all_existing_queues
|
|
184
|
-
known_queues = Backburner::Worker.known_queue_classes.map(&:queue)
|
|
185
|
-
|
|
214
|
+
known_queues = Backburner::Worker.known_queue_classes.select(&:name).map(&:queue) # an anonymous class can't be a job
|
|
215
|
+
prefix = expanded_tube_prefix # the start of every tube name enqueueing with this configuration gives
|
|
216
|
+
namespace = queue_config.tube_namespace
|
|
217
|
+
cut = namespace && "#{namespace}#{queue_config.namespace_separator}".include?(':') # every name is the prefix
|
|
218
|
+
existing_tubes = self.connection.tubes.all.map(&:name).select do |tube| # only the names this configuration can give
|
|
219
|
+
cut ? tube == prefix : tube.start_with?(prefix) && tube[prefix.size..-1] !~ /[A-Z_]/ # the rest is dasherized
|
|
220
|
+
end
|
|
221
|
+
# ceiling: a server tube named exactly like a known queue name is still expanded like that name, as before;
|
|
222
|
+
# the workers' prepare keeping found tubes apart from queue names would lift it
|
|
223
|
+
@existing_tube_names = existing_tubes - known_queues - [queue_config.primary_queue]
|
|
186
224
|
existing_tubes + known_queues + [queue_config.primary_queue]
|
|
187
225
|
end
|
|
188
226
|
|
|
227
|
+
# Leaves the server's tube names all_existing_queues found as they are: expanding one again can change it
|
|
228
|
+
# ("MyApp.default" would become "MyApp.my-app.default")
|
|
229
|
+
def expand_tube_name(tube)
|
|
230
|
+
@existing_tube_names && @existing_tube_names.include?(tube) ? tube : super
|
|
231
|
+
end
|
|
232
|
+
public :expand_tube_name
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Merges the options the block gives for each known queue class (its queue_jobs_limit and so on)
|
|
236
|
+
# into its tube's entry in tubes_data, for tubes that are there. A class that declares its tube
|
|
237
|
+
# overrides the tube's options; a subclass sharing its parent's tube only fills in the ones still
|
|
238
|
+
# unset. Anonymous classes are skipped.
|
|
239
|
+
def merge_tube_settings(tubes_data)
|
|
240
|
+
owners, sharers = Backburner::Worker.known_queue_classes.select(&:name).partition { |queue| !shares_parent_tube?(queue) }
|
|
241
|
+
[[owners, true], [sharers, false]].each do |classes, override|
|
|
242
|
+
classes.each do |queue|
|
|
243
|
+
data = tubes_data[expand_tube_name(queue)]
|
|
244
|
+
next if data.nil?
|
|
245
|
+
data.merge!(yield(queue)) { |_key, current, setting| (override ? setting.nil? : !current.nil?) ? current : setting }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Whether a queue class uses the same tube as its closest named Backburner::Queue ancestor
|
|
251
|
+
def shares_parent_tube?(queue)
|
|
252
|
+
return false unless queue.respond_to?(:superclass)
|
|
253
|
+
parent = queue.superclass
|
|
254
|
+
parent = parent.superclass while parent.is_a?(Backburner::Queue::ClassMethods) && parent.name.nil?
|
|
255
|
+
parent.is_a?(Backburner::Queue::ClassMethods) && expand_tube_name(parent) == expand_tube_name(queue)
|
|
256
|
+
end
|
|
189
257
|
|
|
190
258
|
# Handles an error according to custom definition
|
|
191
259
|
# Used when processing a job that errors out
|
|
@@ -199,6 +267,8 @@ module Backburner
|
|
|
199
267
|
error_handler.call(e, name, args, job)
|
|
200
268
|
end
|
|
201
269
|
end
|
|
270
|
+
rescue => handler_error # an error reporter that fails (can't reach its service, say) mustn't stop the worker
|
|
271
|
+
log_error "The on_error handler failed for #{name}: #{exception_message(handler_error)}"
|
|
202
272
|
end
|
|
203
273
|
|
|
204
274
|
# Normalizes tube names given array of tube_names
|
|
@@ -217,5 +287,26 @@ module Backburner
|
|
|
217
287
|
trap('TERM') { shutdown }
|
|
218
288
|
trap('INT') { shutdown }
|
|
219
289
|
end
|
|
290
|
+
|
|
291
|
+
# Whether a forked child disables the GC as it exits (see with_gc_disabled_on_exit): only on CRuby 3.3
|
|
292
|
+
def self.gc_disabled_on_exit?(engine = RUBY_ENGINE, version = RUBY_VERSION)
|
|
293
|
+
engine == "ruby" && version.start_with?("3.3.")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Runs the body of a forked child, then, on Ruby 3.3, disables the GC before the child exits by any
|
|
297
|
+
# path that tears the interpreter down: returning, an exception, or Kernel.exit (as the TERM and INT
|
|
298
|
+
# traps do). Kernel.exit! skips the ensure, and the teardown with it. The child's at_exit handlers
|
|
299
|
+
# (its own and those it inherited) then run with the GC off.
|
|
300
|
+
# ceiling: works around a Ruby 3.3 bug: a child forked while another thread of its parent runs
|
|
301
|
+
# finalizers can crash as it exits ("[BUG] lazy sweeping underway when freeing object space").
|
|
302
|
+
# GC.disable finishes the lazy sweep first. Fixed in Ruby 3.4 by ruby/ruby 868d63f0a3 (not
|
|
303
|
+
# backported to 3.3, and not seen on 3.1 or 3.2); this can go once Ruby 3.3 support is dropped.
|
|
304
|
+
# Limits: an at_exit handler that calls GC.enable itself can still crash, and a TERM that lands while
|
|
305
|
+
# a ThreadsOnFork child is starting its job threads can leave one of them running during at_exit.
|
|
306
|
+
def with_gc_disabled_on_exit
|
|
307
|
+
yield
|
|
308
|
+
ensure
|
|
309
|
+
GC.disable if Worker.gc_disabled_on_exit?
|
|
310
|
+
end
|
|
220
311
|
end # Worker
|
|
221
312
|
end # Backburner
|
|
@@ -24,6 +24,11 @@ module Backburner
|
|
|
24
24
|
prepare
|
|
25
25
|
loop { work_one_job }
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# A reconnected connection only watches the default tube: watch ours again
|
|
29
|
+
def on_reconnect(conn)
|
|
30
|
+
conn.tubes.watch!(*self.tube_names)
|
|
31
|
+
end
|
|
27
32
|
end # Basic
|
|
28
33
|
end # Workers
|
|
29
34
|
end # Backburner
|
|
@@ -108,13 +108,7 @@ module Backburner
|
|
|
108
108
|
# If the tube is known by existing beanstalkd queue, but not by class - skip it
|
|
109
109
|
#
|
|
110
110
|
def process_tube_options
|
|
111
|
-
|
|
112
|
-
next if @tubes_data[expand_tube_name(queue)].nil?
|
|
113
|
-
queue_settings = {
|
|
114
|
-
:threads => queue.queue_jobs_limit
|
|
115
|
-
}
|
|
116
|
-
@tubes_data[expand_tube_name(queue)].merge!(queue_settings){|k, v1, v2| v2.nil? ? v1 : v2 }
|
|
117
|
-
end
|
|
111
|
+
merge_tube_settings(@tubes_data) { |queue| { :threads => queue.queue_jobs_limit } }
|
|
118
112
|
end
|
|
119
113
|
|
|
120
114
|
# Wait for the shutdown signel
|
|
@@ -108,14 +108,8 @@ module Backburner
|
|
|
108
108
|
# If the tube is known by existing beanstalkd queue, but not by class - skip it
|
|
109
109
|
#
|
|
110
110
|
def process_tube_options
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
queue_settings = {
|
|
114
|
-
:threads => queue.queue_jobs_limit,
|
|
115
|
-
:garbage => queue.queue_garbage_limit,
|
|
116
|
-
:retries => queue.queue_retry_limit
|
|
117
|
-
}
|
|
118
|
-
@tubes_data[expand_tube_name(queue)].merge!(queue_settings){|k, v1, v2| v2.nil? ? v1 : v2 }
|
|
111
|
+
merge_tube_settings(@tubes_data) do |queue|
|
|
112
|
+
{ :threads => queue.queue_jobs_limit, :garbage => queue.queue_garbage_limit, :retries => queue.queue_retry_limit }
|
|
119
113
|
end
|
|
120
114
|
end
|
|
121
115
|
|
|
@@ -140,18 +134,37 @@ module Backburner
|
|
|
140
134
|
end
|
|
141
135
|
end
|
|
142
136
|
|
|
137
|
+
# After a child fails, the parent waits before forking the next one: MIN_RESTART_DELAY seconds,
|
|
138
|
+
# doubling while children keep failing (beanstalkd is down, say) up to MAX_RESTART_DELAY, and
|
|
139
|
+
# starting over once a child is recycled (99) or runs for MAX_RESTART_DELAY seconds
|
|
140
|
+
MIN_RESTART_DELAY = 1
|
|
141
|
+
MAX_RESTART_DELAY = 30
|
|
142
|
+
|
|
143
143
|
# Make the fork and create a thread to watch the child process
|
|
144
144
|
# The exit code '99' means that the fork exited because of the garbage limit
|
|
145
145
|
# Any other code is an error
|
|
146
146
|
def fork_and_watch(name)
|
|
147
147
|
create_thread(name) do |tube_name|
|
|
148
|
+
restart_delay = 0
|
|
148
149
|
until self.class.shutdown
|
|
150
|
+
forked_at = monotonic_time
|
|
149
151
|
pid = fork_tube(tube_name)
|
|
150
152
|
_, status = wait_for_process(pid)
|
|
151
153
|
|
|
152
154
|
# 99 = garbaged
|
|
153
|
-
if status.exitstatus
|
|
154
|
-
|
|
155
|
+
if status.exitstatus == 99
|
|
156
|
+
restart_delay = 0
|
|
157
|
+
elsif self.class.shutdown # stopped by SIGTERM or SIGINT, or killed by finish_forks: not forked again
|
|
158
|
+
log_info(status.exitstatus == 0 ? "Tube #{tube_name} exited normally." : "Tube #{tube_name} stopped.")
|
|
159
|
+
else
|
|
160
|
+
ran_for = monotonic_time - forked_at
|
|
161
|
+
restart_delay = ran_for >= MAX_RESTART_DELAY ? MIN_RESTART_DELAY : (restart_delay * 2).clamp(MIN_RESTART_DELAY, MAX_RESTART_DELAY)
|
|
162
|
+
if status.exitstatus == 0 # stopped by a signal sent to the child alone, or a job called exit
|
|
163
|
+
log_warn("Tube #{tube_name} exited with code 0. Forking it again in #{restart_delay}s.")
|
|
164
|
+
else
|
|
165
|
+
log_error("Catastrophic failure: tube #{tube_name} exited with code #{status.exitstatus}. Forking it again in #{restart_delay}s.")
|
|
166
|
+
end
|
|
167
|
+
Kernel.sleep(restart_delay)
|
|
155
168
|
end
|
|
156
169
|
end
|
|
157
170
|
end
|
|
@@ -182,22 +195,37 @@ module Backburner
|
|
|
182
195
|
@runs = 0
|
|
183
196
|
|
|
184
197
|
if @threads_number == 1
|
|
198
|
+
# Each forked child needs its own socket: the parent's connection is shared by every fork
|
|
199
|
+
self.connection = new_connection
|
|
185
200
|
watch_tube(name)
|
|
186
201
|
run_while_can
|
|
187
202
|
else
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
failed = false
|
|
204
|
+
threads = []
|
|
205
|
+
begin
|
|
206
|
+
@threads_number.times do
|
|
207
|
+
threads << create_thread do
|
|
208
|
+
begin
|
|
209
|
+
conn = new_connection
|
|
210
|
+
watch_tube(name, conn)
|
|
211
|
+
run_while_can(conn)
|
|
212
|
+
rescue Exception
|
|
213
|
+
failed = true
|
|
214
|
+
raise
|
|
215
|
+
ensure
|
|
216
|
+
conn.close if conn
|
|
217
|
+
end
|
|
197
218
|
end
|
|
198
219
|
end
|
|
220
|
+
# Wait for these threads only: others can outlive them (the timeout gem's, since Ruby 3.2)
|
|
221
|
+
sleep 0.1 while threads.any?(&:alive?)
|
|
222
|
+
rescue Exception # TERM, INT or an error: on Ruby 3.3 the child runs its at_exit handlers with the GC
|
|
223
|
+
# off (see with_gc_disabled_on_exit), so its threads mustn't keep working jobs meanwhile
|
|
224
|
+
threads.each(&:kill) if Worker.gc_disabled_on_exit?
|
|
225
|
+
raise
|
|
199
226
|
end
|
|
200
|
-
|
|
227
|
+
# Fail like a single-threaded child whose connection raised, so the parent backs off
|
|
228
|
+
Kernel.exit!(1) if failed
|
|
201
229
|
end
|
|
202
230
|
|
|
203
231
|
coolest_exit
|
|
@@ -233,6 +261,11 @@ module Backburner
|
|
|
233
261
|
Thread.new(*args, &block)
|
|
234
262
|
end
|
|
235
263
|
|
|
264
|
+
# Seconds on a clock that only moves forward. Easy to test
|
|
265
|
+
def monotonic_time
|
|
266
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
267
|
+
end
|
|
268
|
+
|
|
236
269
|
# Wait for a specific process. Easy to test
|
|
237
270
|
def wait_for_process(pid)
|
|
238
271
|
out = Process.wait2(pid)
|
|
@@ -245,9 +278,11 @@ module Backburner
|
|
|
245
278
|
# never shutdown
|
|
246
279
|
def fork_it(&blk)
|
|
247
280
|
pid = Kernel.fork do
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
281
|
+
with_gc_disabled_on_exit do
|
|
282
|
+
self.class.is_child = true
|
|
283
|
+
$0 = "[ThreadsOnFork worker] parent: #{Process.ppid}"
|
|
284
|
+
blk.call
|
|
285
|
+
end
|
|
251
286
|
end
|
|
252
287
|
self.class.child_pids << pid
|
|
253
288
|
pid
|
data/lib/backburner.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'backburner/helpers'
|
|
|
7
7
|
require 'backburner/configuration'
|
|
8
8
|
require 'backburner/logger'
|
|
9
9
|
require 'backburner/connection'
|
|
10
|
+
require 'backburner/enqueue_pool'
|
|
10
11
|
require 'backburner/hooks'
|
|
11
12
|
require 'backburner/performable'
|
|
12
13
|
require 'backburner/worker'
|
|
@@ -15,7 +16,12 @@ require 'backburner/workers/forking'
|
|
|
15
16
|
require 'backburner/workers/threads_on_fork'
|
|
16
17
|
require 'backburner/workers/threading'
|
|
17
18
|
require 'backburner/queue'
|
|
18
|
-
|
|
19
|
+
# On Rails 7.2+ (which added AbstractAdapter) use this gem's Active Job adapter; older Rails autoload theirs.
|
|
20
|
+
# Not when Rails' autoload has already loaded the adapter or is loading it (an adapter file requires backburner).
|
|
21
|
+
if defined?(ActiveJob::QueueAdapters::AbstractAdapter) && (ActiveJob::QueueAdapters.autoload?(:BackburnerAdapter) ||
|
|
22
|
+
!ActiveJob::QueueAdapters.constants(false).include?(:BackburnerAdapter))
|
|
23
|
+
require 'active_job/queue_adapters/backburner_adapter'
|
|
24
|
+
end
|
|
19
25
|
|
|
20
26
|
module Backburner
|
|
21
27
|
class << self
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require File.expand_path('../test_helper', __FILE__)
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
describe "Backburner as the Active Job adapter" do
|
|
6
|
+
%w(active_job_first backburner_first active_job_only).each do |order|
|
|
7
|
+
it "enqueues and works jobs when the app requires #{order.tr('_', ' ')}" do
|
|
8
|
+
namespace = "demo.test.active_job.#{Process.pid}.#{order}"
|
|
9
|
+
app = File.expand_path('../fixtures/active_job_app.rb', __FILE__)
|
|
10
|
+
out, status = Open3.capture2e(RbConfig.ruby, '-w', '-I', File.expand_path('../../lib', __FILE__), app, namespace, order)
|
|
11
|
+
assert status.success?, out
|
|
12
|
+
# only backburner's own files: RubyGems can warn about its own
|
|
13
|
+
refute_match(/circular require considered harmful - #{Regexp.escape(File.expand_path('../../lib', __FILE__))}\//, out)
|
|
14
|
+
result = JSON.parse(out.lines.last)
|
|
15
|
+
|
|
16
|
+
tube = "#{namespace}.adapter"
|
|
17
|
+
assert_equal({ 'tube' => tube, 'pri' => 7, 'state' => 'ready', 'delay' => 0 }, result['jobs']['now'])
|
|
18
|
+
assert_equal({ 'tube' => tube, 'pri' => 3, 'state' => 'ready', 'delay' => 0 }, result['jobs']['urgent'])
|
|
19
|
+
# wait: 3 reaches the adapter a little under 3 seconds ahead: rounded up, so it doesn't run early
|
|
20
|
+
assert_equal({ 'tube' => tube, 'pri' => 7, 'state' => 'delayed', 'delay' => 3 }, result['jobs']['later'])
|
|
21
|
+
assert_equal %w(later now urgent), result['performed']
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/test/back_burner_test.rb
CHANGED
|
@@ -72,6 +72,19 @@ describe "Backburner module" do
|
|
|
72
72
|
Backburner.configuration.namespace_separator = ':'
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
|
+
|
|
76
|
+
it "reads enqueue_connection_max_idle as seconds" do
|
|
77
|
+
max_idle = Backburner.configuration.enqueue_connection_max_idle
|
|
78
|
+
Backburner.configuration.enqueue_connection_max_idle = "30"
|
|
79
|
+
assert_equal 30.0, Backburner.configuration.enqueue_connection_max_idle
|
|
80
|
+
Backburner.configuration.enqueue_connection_max_idle = 1
|
|
81
|
+
assert_equal 1.0, Backburner.configuration.enqueue_connection_max_idle
|
|
82
|
+
Backburner.configuration.enqueue_connection_max_idle = nil
|
|
83
|
+
assert_nil Backburner.configuration.enqueue_connection_max_idle
|
|
84
|
+
assert_raises(ArgumentError) { Backburner.configuration.enqueue_connection_max_idle = "a minute" }
|
|
85
|
+
ensure
|
|
86
|
+
Backburner.configuration.enqueue_connection_max_idle = max_idle
|
|
87
|
+
end
|
|
75
88
|
end # configuration
|
|
76
89
|
|
|
77
90
|
describe "for default_queues" do
|
data/test/connection_test.rb
CHANGED
|
@@ -152,7 +152,29 @@ describe "Backburner::Connection class" do
|
|
|
152
152
|
end
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
+
describe "when a reconnect fails" do
|
|
156
|
+
it "should reconnect on the next call once beanstalkd is back" do
|
|
157
|
+
back_up = Beaneater.new('127.0.0.1:11300')
|
|
158
|
+
Beaneater.expects(:new).twice.raises(Beaneater::NotConnected).then.returns(back_up)
|
|
159
|
+
assert_raises(Beaneater::NotConnected) { @connection.reconnect! }
|
|
160
|
+
assert_equal false, @connection.connected?
|
|
161
|
+
assert_kind_of Array, @connection.tubes.all
|
|
162
|
+
assert_equal true, @connection.connected?
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
155
166
|
describe "when using the retryable method" do
|
|
167
|
+
it "should raise Beaneater::NotConnected once the retries are used up" do
|
|
168
|
+
attempts = 0
|
|
169
|
+
assert_raises Beaneater::NotConnected do
|
|
170
|
+
@connection.retryable(max_retries: 1, retry_delay: 0) do
|
|
171
|
+
attempts += 1
|
|
172
|
+
raise Beaneater::NotConnected
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
assert_equal 2, attempts
|
|
176
|
+
end
|
|
177
|
+
|
|
156
178
|
it "should yield to the block multiple times" do
|
|
157
179
|
expected = 2
|
|
158
180
|
retry_count = 0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# An app using Backburner as its Active Job adapter. active_job_adapter_test.rb runs it in a child process,
|
|
2
|
+
# so Active Job (and Active Support) stay out of the suite's own process.
|
|
3
|
+
# Arguments: the tube namespace, then which of the two the app requires first (or active_job_only).
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'logger' # Active Support 6.1 and 7.0 need Logger loaded first (with concurrent-ruby 1.3.5+)
|
|
6
|
+
|
|
7
|
+
namespace, order = ARGV
|
|
8
|
+
case order
|
|
9
|
+
when 'active_job_first' # a Rails app: rails/all, then Bundler.require
|
|
10
|
+
require 'active_job'
|
|
11
|
+
require 'backburner'
|
|
12
|
+
when 'backburner_first'
|
|
13
|
+
require 'backburner'
|
|
14
|
+
require 'active_job'
|
|
15
|
+
when 'active_job_only' # gem 'backburner', require: false: the adapter loads it
|
|
16
|
+
require 'active_job'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ActiveJob::Base.logger = Logger.new(nil)
|
|
20
|
+
ActiveJob::Base.queue_adapter = :backburner
|
|
21
|
+
Backburner.configure do |config|
|
|
22
|
+
config.beanstalk_url = "beanstalk://127.0.0.1"
|
|
23
|
+
config.tube_namespace = namespace
|
|
24
|
+
config.logger = Logger.new(nil)
|
|
25
|
+
config.reserve_timeout = 5
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
$performed = []
|
|
29
|
+
class AdapterTestJob < ActiveJob::Base
|
|
30
|
+
queue_as :adapter
|
|
31
|
+
queue_with_priority 7
|
|
32
|
+
def perform(name)
|
|
33
|
+
$performed << name
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
enqueued = {
|
|
38
|
+
'now' => AdapterTestJob.perform_later('now'),
|
|
39
|
+
'urgent' => AdapterTestJob.set(priority: 3).perform_later('urgent'),
|
|
40
|
+
'later' => AdapterTestJob.set(wait: 3).perform_later('later')
|
|
41
|
+
}
|
|
42
|
+
beanstalk = Beaneater.new('127.0.0.1')
|
|
43
|
+
jobs = enqueued.map do |name, job|
|
|
44
|
+
stats = job.provider_job_id && beanstalk.jobs.find(job.provider_job_id).stats
|
|
45
|
+
[name, stats && { 'tube' => stats.tube, 'pri' => stats.pri, 'state' => stats.state, 'delay' => stats.delay }]
|
|
46
|
+
end.to_h
|
|
47
|
+
beanstalk.close
|
|
48
|
+
|
|
49
|
+
worker = Backburner::Workers::Simple.new(['adapter'])
|
|
50
|
+
worker.prepare
|
|
51
|
+
enqueued.size.times { worker.work_one_job }
|
|
52
|
+
|
|
53
|
+
puts JSON.generate('jobs' => jobs, 'performed' => $performed.sort)
|
data/test/fixtures/test_jobs.rb
CHANGED
|
@@ -13,6 +13,22 @@ class TestJob
|
|
|
13
13
|
def self.perform(x, y); $worker_test_count += x + y; end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
class TestParentQueueJob
|
|
17
|
+
include Backburner::Queue
|
|
18
|
+
queue "test-parent"
|
|
19
|
+
queue_priority :high
|
|
20
|
+
queue_respond_timeout 250
|
|
21
|
+
def self.perform(x, y); $worker_test_count += x + y; end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class TestChildQueueJob < TestParentQueueJob; end
|
|
25
|
+
|
|
26
|
+
class TestNamedQueueJob
|
|
27
|
+
include Backburner::Queue
|
|
28
|
+
queue lambda { |klass| klass.name.downcase }
|
|
29
|
+
def self.perform; end
|
|
30
|
+
end
|
|
31
|
+
|
|
16
32
|
class TestSlowJob
|
|
17
33
|
include Backburner::Queue
|
|
18
34
|
queue_priority :medium
|
|
@@ -45,6 +61,21 @@ class TestRetryJob
|
|
|
45
61
|
end
|
|
46
62
|
end
|
|
47
63
|
|
|
64
|
+
class TestFloatRetryDelayJob
|
|
65
|
+
include Backburner::Queue
|
|
66
|
+
queue_retry_delay 1.5
|
|
67
|
+
def self.perform(retry_count)
|
|
68
|
+
raise RuntimeError
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class TestFloatRespondTimeoutJob
|
|
73
|
+
include Backburner::Queue
|
|
74
|
+
queue "test-plain"
|
|
75
|
+
queue_respond_timeout 1.5
|
|
76
|
+
def self.perform; end
|
|
77
|
+
end
|
|
78
|
+
|
|
48
79
|
class TestConfigurableRetryJob
|
|
49
80
|
include Backburner::Queue
|
|
50
81
|
def self.perform(retry_count)
|
|
@@ -85,3 +116,25 @@ class TestLambdaQueueJob
|
|
|
85
116
|
queue lambda { |klass| klass.calculated_queue_name }
|
|
86
117
|
def self.calculated_queue_name; 'lambda-queue' end
|
|
87
118
|
end
|
|
119
|
+
|
|
120
|
+
class TestOverTTRJob
|
|
121
|
+
include Backburner::Queue
|
|
122
|
+
queue_respond_timeout 2
|
|
123
|
+
# Outlives its TTR without Timeout being able to interrupt it (like a blocking C call)
|
|
124
|
+
def self.perform; Thread.handle_interrupt(Object => :never) { sleep 3 }; end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Like an autoloader that doesn't find a file for the constant
|
|
128
|
+
module AutoloadingDemo
|
|
129
|
+
def self.const_missing(name); raise LoadError, "cannot load such file -- autoloading_demo/#{name}"; end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Job classes an app autoloads: whose files fail to load, and one whose file isn't there
|
|
133
|
+
autoload :TestBrokenNameJob, File.expand_path('../autoload/test_broken_name_job', __FILE__)
|
|
134
|
+
autoload :TestBrokenRequireJob, File.expand_path('../autoload/test_broken_require_job', __FILE__)
|
|
135
|
+
autoload :TestBrokenNoMethodJob, File.expand_path('../autoload/test_broken_no_method_job', __FILE__)
|
|
136
|
+
autoload :TestNoFileJob, File.expand_path('../autoload/test_no_such_job', __FILE__)
|
|
137
|
+
autoload :TestBrokenRuntimeJob, File.expand_path('../autoload/test_broken_runtime_job', __FILE__)
|
|
138
|
+
autoload :TestSameNameJob, File.expand_path('../autoload/test_same_name_job', __FILE__)
|
|
139
|
+
autoload :TestBrokenSyntaxJob, File.expand_path('../autoload/test_broken_syntax_job', __FILE__)
|
|
140
|
+
autoload :TestBrokenTimeoutJob, File.expand_path('../autoload/test_broken_timeout_job', __FILE__)
|