backburner 1.7.0 → 1.9.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -4
  3. data/HOOKS.md +23 -14
  4. data/README.md +102 -23
  5. data/bin/backburner +1 -1
  6. data/lib/active_job/queue_adapters/backburner_adapter.rb +19 -5
  7. data/lib/backburner/cli.rb +1 -1
  8. data/lib/backburner/configuration.rb +7 -0
  9. data/lib/backburner/connection.rb +4 -2
  10. data/lib/backburner/enqueue_pool.rb +90 -0
  11. data/lib/backburner/helpers.rb +77 -22
  12. data/lib/backburner/job.rb +46 -7
  13. data/lib/backburner/logger.rb +9 -0
  14. data/lib/backburner/performable.rb +29 -4
  15. data/lib/backburner/queue.rb +37 -19
  16. data/lib/backburner/version.rb +1 -1
  17. data/lib/backburner/worker.rb +150 -25
  18. data/lib/backburner/workers/forking.rb +4 -2
  19. data/lib/backburner/workers/simple.rb +5 -0
  20. data/lib/backburner/workers/threading.rb +17 -7
  21. data/lib/backburner/workers/threads_on_fork.rb +59 -23
  22. data/lib/backburner.rb +7 -1
  23. metadata +40 -66
  24. data/.gitignore +0 -17
  25. data/.travis.yml +0 -29
  26. data/CONTRIBUTING.md +0 -37
  27. data/Gemfile +0 -4
  28. data/Rakefile +0 -17
  29. data/TODO +0 -4
  30. data/backburner.gemspec +0 -28
  31. data/circle.yml +0 -3
  32. data/examples/custom.rb +0 -25
  33. data/examples/demo.rb +0 -60
  34. data/examples/god.rb +0 -46
  35. data/examples/hooked.rb +0 -87
  36. data/examples/retried.rb +0 -31
  37. data/examples/simple.rb +0 -43
  38. data/examples/stress.rb +0 -31
  39. data/test/async_proxy_test.rb +0 -36
  40. data/test/back_burner_test.rb +0 -88
  41. data/test/connection_test.rb +0 -179
  42. data/test/fixtures/active_jobs.rb +0 -11
  43. data/test/fixtures/hooked.rb +0 -122
  44. data/test/fixtures/test_fork_jobs.rb +0 -72
  45. data/test/fixtures/test_forking_jobs.rb +0 -56
  46. data/test/fixtures/test_jobs.rb +0 -87
  47. data/test/fixtures/test_queue_settings.rb +0 -14
  48. data/test/helpers/templogger.rb +0 -22
  49. data/test/helpers_test.rb +0 -278
  50. data/test/hooks_test.rb +0 -112
  51. data/test/job_test.rb +0 -185
  52. data/test/logger_test.rb +0 -44
  53. data/test/performable_test.rb +0 -88
  54. data/test/queue_test.rb +0 -69
  55. data/test/test_helper.rb +0 -125
  56. data/test/worker_test.rb +0 -157
  57. data/test/workers/forking_worker_test.rb +0 -181
  58. data/test/workers/simple_worker_test.rb +0 -350
  59. data/test/workers/threading_worker_test.rb +0 -104
  60. data/test/workers/threads_on_fork_worker_test.rb +0 -484
@@ -11,7 +11,7 @@ module Backburner
11
11
 
12
12
  base = File.expand_path(Dir.pwd) + '/'
13
13
  e.backtrace.each do |t|
14
- msg << " #{File.expand_path(t).gsub(/#{base}/, '')}"
14
+ msg << " #{File.expand_path(t).gsub(base, '')}"
15
15
  end if e.backtrace
16
16
 
17
17
  msg.join("\n")
@@ -44,20 +44,13 @@ module Backburner
44
44
  # constantize("Module") # => Module
45
45
  # constantize("Test::Unit") # => Test::Unit
46
46
  #
47
- # NameError is raised when the constant is unknown.
47
+ # NameError is raised when the constant is unknown. Given a block, yields each module and the name
48
+ # of the constant it is about to look up in it, so a caller can tell which one failed.
48
49
  def constantize(camel_cased_word)
49
- camel_cased_word = camel_cased_word.to_s
50
-
51
- if camel_cased_word.include?('-')
52
- camel_cased_word = classify(camel_cased_word)
53
- end
54
-
55
- names = camel_cased_word.split('::')
56
- names.shift if names.empty? || names.first.empty?
57
-
58
50
  constant = Object
59
- names.each do |name|
51
+ constant_names(camel_cased_word).each do |name|
60
52
  args = Module.method(:const_get).arity != 1 ? [false] : []
53
+ yield constant, name if block_given?
61
54
 
62
55
  if constant.const_defined?(name, *args)
63
56
  constant = constant.const_get(name)
@@ -68,6 +61,23 @@ module Backburner
68
61
  constant
69
62
  end
70
63
 
64
+ # The names of the constants constantize looks up in turn
65
+ #
66
+ # @example
67
+ # constant_names("Test::Unit") # => ["Test", "Unit"]
68
+ #
69
+ def constant_names(camel_cased_word)
70
+ camel_cased_word = camel_cased_word.to_s
71
+
72
+ if camel_cased_word.include?('-')
73
+ camel_cased_word = classify(camel_cased_word)
74
+ end
75
+
76
+ names = camel_cased_word.split('::')
77
+ names.shift if names.empty? || names.first.empty?
78
+ names
79
+ end
80
+
71
81
  # Returns configuration options for backburner
72
82
  #
73
83
  # @example
@@ -85,7 +95,7 @@ module Backburner
85
95
  # expand_tube_name(FooJob) # => <prefix>.foo-job
86
96
  #
87
97
  def expand_tube_name(tube)
88
- prefix = queue_config.tube_namespace
98
+ prefix = queue_config.tube_namespace # nil or false mean no prefix; '' gives names that start with the separator, as in 1.7.0
89
99
  separator = queue_config.namespace_separator
90
100
  queue_name = if tube.is_a?(String)
91
101
  tube
@@ -99,7 +109,29 @@ module Backburner
99
109
  else # turn into a string
100
110
  tube.to_s
101
111
  end
102
- [prefix.gsub(/\.$/, ''), dasherize(queue_name).gsub(/^#{prefix}/, '')].join(separator).gsub(/#{Regexp::escape(separator)}+/, separator).split(':').first
112
+ tube_name = dasherize(queue_name)
113
+ tube_name = [separator.empty? ? prefix : prefix.gsub(/\.$/, ''), tube_name.gsub(namespace_pattern(prefix), '')].join(separator) if prefix
114
+ tube_name = tube_name.gsub(/#{Regexp::escape(separator)}+/, separator) unless separator.empty? # /+/ isn't a regexp
115
+ tube_name.split(':').first
116
+ end
117
+
118
+ # What every tube name expand_tube_name gives starts with: the namespace and its separator ('' for a nil or false one)
119
+ #
120
+ # @example
121
+ # expanded_tube_prefix # => "<prefix>."
122
+ #
123
+ def expanded_tube_prefix
124
+ expand_tube_name('').to_s
125
+ end
126
+
127
+ # The namespace as the pattern expand_tube_name strips from the start of a queue name.
128
+ # ceiling: a namespace that is a valid regexp is still matched as one ("app.prod" also strips "app-prod"), because
129
+ # matching it literally would move the jobs of the queue names it matches to other tubes; one that isn't ("app(")
130
+ # used to fail every enqueue and is matched literally.
131
+ def namespace_pattern(prefix)
132
+ Regexp.new("^#{prefix}")
133
+ rescue RegexpError
134
+ Regexp.new("^#{Regexp.escape(prefix)}")
103
135
  end
104
136
 
105
137
  # Resolves job priority based on the value given. Can be integer, a class or nothing
@@ -110,21 +142,31 @@ module Backburner
110
142
  # resolve_priority(nil) => <default priority>
111
143
  #
112
144
  def resolve_priority(pri)
145
+ priority = resolve_priority_value(pri)
146
+ return priority if priority
147
+ default = Backburner.configuration.default_priority # can be a label too
148
+ resolve_priority_value(default) || default # left as configured if it isn't a known label
149
+ end
150
+
151
+ # Follows a job class's queue_priority and priority labels to a number; nil for nothing,
152
+ # an unknown label or a cycle of labels
153
+ def resolve_priority_value(pri, seen_labels = [])
113
154
  if pri.respond_to?(:queue_priority)
114
- resolve_priority(pri.queue_priority)
155
+ resolve_priority_value(pri.queue_priority, seen_labels)
115
156
  elsif pri.is_a?(String) || pri.is_a?(Symbol) # named priority
116
- resolve_priority(Backburner.configuration.priority_labels[pri.to_sym])
157
+ label = pri.to_sym
158
+ resolve_priority_value(Backburner.configuration.priority_labels[label], seen_labels + [label]) unless seen_labels.include?(label)
117
159
  elsif pri.is_a?(Integer) # numerical
118
160
  pri
119
- else # default
120
- Backburner.configuration.default_priority
121
161
  end
122
162
  end
123
163
 
124
- # Resolves job respond timeout based on the value given. Can be integer, a class or nothing
164
+ # Resolves job respond timeout based on the value given, rounded up to whole seconds. Can be a number,
165
+ # a class or nothing
125
166
  #
126
167
  # @example
127
168
  # resolve_respond_timeout(1000) => 1000
169
+ # resolve_respond_timeout(1.5) => 2
128
170
  # resolve_respond_timeout(FooBar) => <queue respond_timeout>
129
171
  # resolve_respond_timeout(nil) => <default respond_timeout>
130
172
  #
@@ -133,8 +175,11 @@ module Backburner
133
175
  resolve_respond_timeout(ttr.queue_respond_timeout)
134
176
  elsif ttr.is_a?(Integer) # numerical
135
177
  ttr
178
+ elsif ttr.is_a?(Numeric) && ttr.finite? # fractional
179
+ whole_seconds(ttr)
136
180
  else # default
137
- Backburner.configuration.respond_timeout
181
+ ttr = Backburner.configuration.respond_timeout
182
+ !ttr.is_a?(Integer) && ttr.is_a?(Numeric) && ttr.finite? ? whole_seconds(ttr) : ttr
138
183
  end
139
184
  end
140
185
 
@@ -155,7 +200,7 @@ module Backburner
155
200
  end
156
201
  end
157
202
 
158
- # Resolves retry delay based on the value given. Can be integer, a class or nothing
203
+ # Resolves retry delay based on the value given. Can be a number, a class or nothing
159
204
  #
160
205
  # @example
161
206
  # resolve_retry_delay(5) => 5
@@ -165,7 +210,7 @@ module Backburner
165
210
  def resolve_retry_delay(delay)
166
211
  if delay.respond_to?(:queue_retry_delay)
167
212
  resolve_retry_delay(delay.queue_retry_delay)
168
- elsif delay.is_a?(Integer) # numerical
213
+ elsif delay.is_a?(Numeric) && delay.finite? # numerical (the worker rounds the delay it computes from it up)
169
214
  delay
170
215
  else # default
171
216
  Backburner.configuration.retry_delay
@@ -189,5 +234,15 @@ module Backburner
189
234
  end
190
235
  end
191
236
 
237
+ # Rounds a delay up to the whole seconds beanstalkd takes, so a job is never ready early
238
+ #
239
+ # @example
240
+ # whole_seconds(1.5) => 2
241
+ # whole_seconds(nil) => 0
242
+ #
243
+ def whole_seconds(delay)
244
+ [0, delay.to_f.ceil].max
245
+ end
246
+
192
247
  end # Helpers
193
248
  end # Backburner
@@ -45,12 +45,15 @@ module Backburner
45
45
  def process
46
46
  # Invoke before hook and stop if false
47
47
  res = @hooks.invoke_hook_events(job_name, :before_perform, *args)
48
- return false unless res
48
+ unless res
49
+ task.delete # aborted: don't leave it reserved, or beanstalkd hands it out again after its TTR
50
+ return false
51
+ end
49
52
  # Execute the job
50
53
  @hooks.around_hook_events(job_name, :around_perform, *args) do
51
- # We subtract one to ensure we timeout before beanstalkd does, except if:
52
- # a) ttr == 0, to support never timing out
53
- # b) ttr == 1, so that we don't accidentally set it to never time out
54
+ # We subtract one to ensure we timeout before beanstalkd does, except if
55
+ # ttr == 1 (beanstalkd's minimum: it stores a ttr of 0 as 1), so that we
56
+ # don't accidentally set it to never time out.
54
57
  # NB: A ttr of 1 will likely result in race conditions between
55
58
  # Backburner and beanstalkd and should probably be avoided
56
59
  timeout_job_after(task.ttr > 1 ? task.ttr - 1 : task.ttr) { job_class.perform(*args) }
@@ -80,10 +83,16 @@ module Backburner
80
83
  #
81
84
  def job_class
82
85
  handler = try_job_class
83
- raise(JobNotFound, self.name) unless handler
86
+ raise(JobNotFound, @job_class_error ? "#{name} (#{@job_class_error.class}: #{@job_class_error.message.to_s.lines.first.to_s.chomp})" : name) unless handler
84
87
  handler
85
88
  end
86
89
 
90
+ # Whether the last lookup of the job's class found it missing (not defined, or an autoload whose
91
+ # file isn't there) rather than failing to load it (its file raised, or required a missing gem)
92
+ def job_class_missing?
93
+ !!@job_class_missing
94
+ end
95
+
87
96
  protected
88
97
 
89
98
  # Attempts to return a constantized job name, otherwise reverts to the name string
@@ -96,11 +105,41 @@ module Backburner
96
105
  end
97
106
 
98
107
  def try_job_class
99
- constantize(self.name)
100
- rescue NameError
108
+ @job_class_error = nil
109
+ @job_class_missing = false
110
+ scope = segment = autoload_path = nil
111
+ invalid_name = !constant_names(self.name).all? { |name| constant_name?(name) }
112
+ constantize(self.name) do |mod, name|
113
+ scope, segment = mod, name
114
+ autoload_path = mod.autoload?(name) rescue nil # a module, or the value of a constant that isn't one
115
+ end
116
+ rescue StandardError, ScriptError => e # its file may raise anything; an autoloader may raise LoadError for a file that isn't there
117
+ @job_class_error = e
118
+ @job_class_missing = !invalid_name && missing_constant_error?(e, scope, segment, autoload_path)
101
119
  nil
102
120
  end
103
121
 
122
+ # Whether a name can be a constant's: one that can't ("not a class", "123") will never be found
123
+ def constant_name?(name)
124
+ Module.new.const_defined?(name, false) # raises NameError for one that can't
125
+ true
126
+ rescue NameError
127
+ false
128
+ end
129
+
130
+ # Whether an error raised while looking up `segment` in `scope` says that constant is missing,
131
+ # rather than coming from inside the file that defines it (a NameError for another constant, or for
132
+ # one of the same name in another module, a NoMethodError, a require of a missing gem, any other
133
+ # error or a syntax error)
134
+ def missing_constant_error?(e, scope, segment, autoload_path)
135
+ case e
136
+ when NoMethodError then false
137
+ when NameError then e.name.to_s == segment.to_s && (e.receiver.equal?(scope) rescue true) # no receiver: by name only
138
+ when LoadError then e.path.nil? || e.path == autoload_path
139
+ else false
140
+ end
141
+ end
142
+
104
143
  # Timeout job within specified block after given time.
105
144
  #
106
145
  # @example
@@ -45,6 +45,15 @@ module Backburner
45
45
  logger ? logger.error(msg) : $stderr.puts(msg)
46
46
  end
47
47
 
48
+ # Print a warning to stderr
49
+ #
50
+ # @example
51
+ # log_warn("Task released!")
52
+ #
53
+ def log_warn(msg)
54
+ logger ? (logger.respond_to?(:warn) ? logger.warn(msg) : logger.error(msg)) : $stderr.puts(msg)
55
+ end
56
+
48
57
  # Return logger if specified
49
58
  def logger
50
59
  Backburner.configuration.logger
@@ -28,14 +28,16 @@ module Backburner
28
28
  Backburner::AsyncProxy.new(self, nil, opts)
29
29
  end
30
30
 
31
- # Defines perform method for job processing
31
+ # Defines perform method for job processing. It calls a method the app defines on the class,
32
+ # its superclasses or the modules they include, and raises NoMethodError for any other.
32
33
  # @example
33
34
  # perform(55, :do_something, "foo", "bar")
34
35
  def perform(id, method, *args)
35
36
  if id # instance
36
- find(id).send(method, *args)
37
+ record = find(id)
38
+ Backburner::Performable.perform_job_method(record, record.class, Object, method, args)
37
39
  else # class method
38
- send(method, *args)
40
+ Backburner::Performable.perform_job_method(self, singleton_class, Object.singleton_class, method, args)
39
41
  end
40
42
  end # perform
41
43
 
@@ -79,7 +81,7 @@ module Backburner
79
81
  with_async_name = :"#{aliased_method}_with_async#{punctuation}"
80
82
  without_async_name = :"#{aliased_method}_without_async#{punctuation}"
81
83
 
82
- klass.send(:include, Performable) unless included_modules.include?(Performable)
84
+ klass.send(:include, Performable) unless klass.included_modules.include?(Performable)
83
85
  klass_eval_scope.class_eval do
84
86
  define_method with_async_name do |*args|
85
87
  async(opts).__send__ without_async_name, *args
@@ -90,6 +92,29 @@ module Backburner
90
92
  end
91
93
  private_class_method :_handle_asynchronously
92
94
 
95
+ # Calls a job's method on `receiver`. It raises NoMethodError when the method isn't one the app
96
+ # defined: one that `methods_of` (the receiver's class, or the singleton class for a class method)
97
+ # gets from `core` (Object, or Object's singleton class for a class method) or the modules mixed
98
+ # into it, from Backburner's own modules, or Ruby's method_missing hook. A method with the same name
99
+ # as one of core's must be the app's own definition: one that method_missing would handle, or one
100
+ # from Delegator's copy of Kernel (the module delegate.rb aliases __raise__ in), is refused too.
101
+ def self.perform_job_method(receiver, methods_of, core, method, args)
102
+ owner = begin
103
+ methods_of.instance_method(method).owner
104
+ rescue NameError
105
+ nil
106
+ end
107
+ refused = if owner && (core.ancestors.include?(owner) || [InstanceMethods, ClassMethods, Backburner::Queue::ClassMethods].include?(owner))
108
+ true
109
+ elsif core.method_defined?(method) || core.private_method_defined?(method)
110
+ owner.nil? || method.to_sym == :method_missing || (defined?(::Delegator) && owner.equal?(::Delegator.instance_method(:__raise__).owner))
111
+ end
112
+ if refused
113
+ kind = methods_of.singleton_class? ? "class method" : "method"
114
+ raise NoMethodError.new("undefined job #{kind} '#{method}' for #{methods_of.singleton_class? ? receiver : methods_of}", method.to_sym, receiver: receiver)
115
+ end
116
+ receiver.__send__(method, *args)
117
+ end
93
118
 
94
119
  end # Performable
95
120
  end # Backburner
@@ -1,17 +1,14 @@
1
1
  module Backburner
2
2
  module Queue
3
3
  def self.included(base)
4
- base.instance_variable_set(:@queue_name, nil)
5
- base.instance_variable_set(:@queue_priority, nil)
6
- base.instance_variable_set(:@queue_respond_timeout, nil)
7
- base.instance_variable_set(:@queue_max_job_retries, nil)
8
- base.instance_variable_set(:@queue_retry_delay, nil)
9
- base.instance_variable_set(:@queue_retry_delay_proc, nil)
10
- base.instance_variable_set(:@queue_jobs_limit, nil)
11
- base.instance_variable_set(:@queue_garbage_limit, nil)
12
- base.instance_variable_set(:@queue_retry_limit, nil)
4
+ # Initialize the settings, but keep any already set: including Queue again (as including
5
+ # Performable does) must not reset them
6
+ [:@queue_name, :@queue_priority, :@queue_respond_timeout, :@queue_max_job_retries, :@queue_retry_delay,
7
+ :@queue_retry_delay_proc, :@queue_jobs_limit, :@queue_garbage_limit, :@queue_retry_limit].each do |setting|
8
+ base.instance_variable_set(setting, nil) unless base.instance_variable_defined?(setting)
9
+ end
13
10
  base.extend ClassMethods
14
- Backburner::Worker.known_queue_classes << base
11
+ Backburner::Worker.known_queue_classes << base unless Backburner::Worker.known_queue_classes.include?(base)
15
12
  end
16
13
 
17
14
  module ClassMethods
@@ -25,7 +22,8 @@ module Backburner
25
22
  if name
26
23
  @queue_name = name
27
24
  else # accessor
28
- (@queue_name.is_a?(Proc) ? @queue_name.call(self) : @queue_name) || Backburner.configuration.primary_queue
25
+ queue_name = inherited_queue_setting(:@queue_name)
26
+ (queue_name.is_a?(Proc) ? queue_name.call(self) : queue_name) || Backburner.configuration.primary_queue
29
27
  end
30
28
  end
31
29
 
@@ -39,7 +37,7 @@ module Backburner
39
37
  if pri
40
38
  @queue_priority = pri
41
39
  else # accessor
42
- @queue_priority
40
+ inherited_queue_setting(:@queue_priority)
43
41
  end
44
42
  end
45
43
 
@@ -53,7 +51,7 @@ module Backburner
53
51
  if ttr
54
52
  @queue_respond_timeout = ttr
55
53
  else # accessor
56
- @queue_respond_timeout
54
+ inherited_queue_setting(:@queue_respond_timeout)
57
55
  end
58
56
  end
59
57
 
@@ -67,7 +65,7 @@ module Backburner
67
65
  if delay
68
66
  @queue_max_job_retries = delay
69
67
  else # accessor
70
- @queue_max_job_retries
68
+ inherited_queue_setting(:@queue_max_job_retries)
71
69
  end
72
70
  end
73
71
 
@@ -81,7 +79,7 @@ module Backburner
81
79
  if delay
82
80
  @queue_retry_delay = delay
83
81
  else # accessor
84
- @queue_retry_delay
82
+ inherited_queue_setting(:@queue_retry_delay)
85
83
  end
86
84
  end
87
85
 
@@ -95,7 +93,7 @@ module Backburner
95
93
  if proc
96
94
  @queue_retry_delay_proc = proc
97
95
  else # accessor
98
- @queue_retry_delay_proc
96
+ inherited_queue_setting(:@queue_retry_delay_proc)
99
97
  end
100
98
  end
101
99
 
@@ -109,7 +107,7 @@ module Backburner
109
107
  if limit
110
108
  @queue_jobs_limit = limit
111
109
  else #accessor
112
- @queue_jobs_limit
110
+ inherited_queue_setting(:@queue_jobs_limit)
113
111
  end
114
112
  end
115
113
 
@@ -123,7 +121,7 @@ module Backburner
123
121
  if limit
124
122
  @queue_garbage_limit = limit
125
123
  else #accessor
126
- @queue_garbage_limit
124
+ inherited_queue_setting(:@queue_garbage_limit)
127
125
  end
128
126
  end
129
127
 
@@ -137,8 +135,28 @@ module Backburner
137
135
  if limit
138
136
  @queue_retry_limit = limit
139
137
  else #accessor
140
- @queue_retry_limit
138
+ inherited_queue_setting(:@queue_retry_limit)
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ # Registers subclasses as known queues too (they inherit the queue settings)
145
+ def inherited(subclass)
146
+ super
147
+ Backburner::Worker.known_queue_classes << subclass unless Backburner::Worker.known_queue_classes.include?(subclass)
148
+ end
149
+
150
+ # Returns the setting from this class or else from the closest superclass that has it,
151
+ # so a subclass shares its parent's queue settings unless it sets its own
152
+ def inherited_queue_setting(ivar)
153
+ klass = self
154
+ while klass.is_a?(ClassMethods)
155
+ value = klass.instance_variable_get(ivar) if klass.instance_variable_defined?(ivar)
156
+ return value unless value.nil?
157
+ klass = klass.respond_to?(:superclass) ? klass.superclass : nil
141
158
  end
159
+ nil
142
160
  end
143
161
  end # ClassMethods
144
162
  end # Queue
@@ -1,3 +1,3 @@
1
1
  module Backburner
2
- VERSION = "1.7.0"
2
+ VERSION = "1.9.0"
3
3
  end