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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +45 -0
  3. data/.github/workflows/github-release.yml +58 -0
  4. data/.github/workflows/release.yml +221 -0
  5. data/CHANGELOG.md +29 -0
  6. data/Gemfile +4 -1
  7. data/HOOKS.md +23 -14
  8. data/README.md +32 -9
  9. data/backburner.gemspec +3 -0
  10. data/bin/backburner +1 -1
  11. data/lib/active_job/queue_adapters/backburner_adapter.rb +10 -3
  12. data/lib/backburner/configuration.rb +7 -0
  13. data/lib/backburner/connection.rb +4 -2
  14. data/lib/backburner/enqueue_pool.rb +88 -0
  15. data/lib/backburner/helpers.rb +77 -22
  16. data/lib/backburner/job.rb +46 -7
  17. data/lib/backburner/logger.rb +9 -0
  18. data/lib/backburner/performable.rb +1 -1
  19. data/lib/backburner/queue.rb +37 -19
  20. data/lib/backburner/version.rb +1 -1
  21. data/lib/backburner/worker.rb +112 -21
  22. data/lib/backburner/workers/forking.rb +4 -2
  23. data/lib/backburner/workers/simple.rb +5 -0
  24. data/lib/backburner/workers/threading.rb +1 -7
  25. data/lib/backburner/workers/threads_on_fork.rb +58 -23
  26. data/lib/backburner.rb +7 -1
  27. data/test/active_job_adapter_test.rb +24 -0
  28. data/test/back_burner_test.rb +13 -0
  29. data/test/connection_test.rb +22 -0
  30. data/test/fixtures/active_job_app.rb +53 -0
  31. data/test/fixtures/autoload/test_broken_name_job.rb +4 -0
  32. data/test/fixtures/autoload/test_broken_no_method_job.rb +4 -0
  33. data/test/fixtures/autoload/test_broken_require_job.rb +4 -0
  34. data/test/fixtures/autoload/test_broken_runtime_job.rb +4 -0
  35. data/test/fixtures/autoload/test_broken_syntax_job.rb +4 -0
  36. data/test/fixtures/autoload/test_broken_timeout_job.rb +4 -0
  37. data/test/fixtures/autoload/test_same_name_job.rb +5 -0
  38. data/test/fixtures/test_fork_jobs.rb +5 -0
  39. data/test/fixtures/test_jobs.rb +53 -0
  40. data/test/fixtures/test_queue_settings.rb +41 -1
  41. data/test/helpers/templogger.rb +1 -1
  42. data/test/helpers_test.rb +184 -0
  43. data/test/job_test.rb +22 -0
  44. data/test/logger_test.rb +27 -0
  45. data/test/performable_test.rb +50 -0
  46. data/test/queue_test.rb +63 -0
  47. data/test/test_helper.rb +57 -0
  48. data/test/worker_test.rb +346 -1
  49. data/test/workers/forking_worker_test.rb +19 -1
  50. data/test/workers/simple_worker_test.rb +272 -0
  51. data/test/workers/threading_worker_test.rb +22 -1
  52. data/test/workers/threads_on_fork_worker_test.rb +279 -12
  53. metadata +54 -6
  54. data/.travis.yml +0 -29
  55. data/circle.yml +0 -3
@@ -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), `queue` (queue name)
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 = [0, opts[:delay].to_i].max
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
- begin
38
- response = nil
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
- num_retries = job.stats.releases
153
- max_job_retries = resolve_max_job_retries(job.job_class)
154
- retry_status = "failed: attempt #{num_retries+1} of #{max_job_retries+1}"
155
- if num_retries < max_job_retries # retry again
156
- retry_delay = resolve_retry_delay(job.job_class)
157
- delay = resolve_retry_delay_proc(job.job_class).call(retry_delay, num_retries) rescue retry_delay
158
- job.retry(num_retries + 1, delay)
159
- self.log_job_end(job.name, "#{retry_status}, retrying in #{delay}s") if job_started_at
160
- else # retries failed, bury
161
- job.bury
162
- self.log_job_end(job.name, "#{retry_status}, burying") if job_started_at
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
- existing_tubes = self.connection.tubes.all.map(&:name).select { |tube| tube =~ /^#{queue_config.tube_namespace}/ }
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
@@ -29,8 +29,10 @@ module Backburner
29
29
  # Waits for a job, works the job, and exits
30
30
  def fork_one_job
31
31
  pid = Process.fork do
32
- work_one_job
33
- coolest_exit
32
+ with_gc_disabled_on_exit do
33
+ work_one_job
34
+ coolest_exit
35
+ end
34
36
  end
35
37
  Process.wait(pid)
36
38
  end
@@ -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
- Backburner::Worker.known_queue_classes.each do |queue|
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
- Backburner::Worker.known_queue_classes.each do |queue|
112
- next if @tubes_data[expand_tube_name(queue)].nil?
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 != 99
154
- log_error("Catastrophic failure: tube #{tube_name} exited with code #{status.exitstatus}.")
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
- threads_count = Thread.list.count
189
- @threads_number.times do
190
- create_thread do
191
- begin
192
- conn = new_connection
193
- watch_tube(name, conn)
194
- run_while_can(conn)
195
- ensure
196
- conn.close if conn
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
- sleep 0.1 while Thread.list.count > threads_count
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
- self.class.is_child = true
249
- $0 = "[ThreadsOnFork worker] parent: #{Process.ppid}"
250
- blk.call
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
- require 'active_job/queue_adapters/backburner_adapter' if defined?(ActiveJob)
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
@@ -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
@@ -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)
@@ -0,0 +1,4 @@
1
+ class TestBrokenNameJob
2
+ include TestMissingConcern # renamed or removed: NameError for another constant while the file loads
3
+ def self.perform(x); end
4
+ end
@@ -0,0 +1,4 @@
1
+ class TestBrokenNoMethodJob
2
+ queue_respond_timout 30 # a typo: NoMethodError while the file loads
3
+ def self.perform(x); end
4
+ end
@@ -0,0 +1,4 @@
1
+ require 'backburner_test_missing_gem' # a gem left out of the bundle: LoadError while the file loads
2
+ class TestBrokenRequireJob
3
+ def self.perform(x); end
4
+ end
@@ -0,0 +1,4 @@
1
+ class TestBrokenRuntimeJob
2
+ raise ArgumentError, "bad config" # an error of another kind while the file loads (ENV.fetch raising KeyError, say)
3
+ def self.perform(x); end
4
+ end
@@ -0,0 +1,4 @@
1
+ class TestBrokenSyntaxJob
2
+ def self.perform(x)
3
+ if # a syntax error: SyntaxError while the file loads
4
+ end
@@ -0,0 +1,4 @@
1
+ class TestBrokenTimeoutJob
2
+ raise Timeout::Error, "class body call timed out" # a call in the class body that timed out (Net::OpenTimeout is a Timeout::Error)
3
+ def self.perform(x); end
4
+ end
@@ -0,0 +1,5 @@
1
+ module TestOtherNamespace; end
2
+ class TestSameNameJob
3
+ OTHER = TestOtherNamespace::TestSameNameJob # NameError for a constant of the same name in another module
4
+ def self.perform(x); end
5
+ end
@@ -70,3 +70,8 @@ class TestJobMultithreadFork
70
70
  }], :queue => 'response'
71
71
  end
72
72
  end
73
+
74
+ class TestJobNoopFork
75
+ include Backburner::Queue
76
+ def self.perform; end
77
+ end
@@ -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__)