activejob-retry 0.0.1 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +22 -0
  3. data/.travis.yml +20 -5
  4. data/CHANGELOG.md +3 -0
  5. data/Gemfile +9 -6
  6. data/Gemfile.lock +63 -51
  7. data/LICENSE +1 -1
  8. data/README.md +57 -25
  9. data/Rakefile +14 -13
  10. data/activejob-retry.gemspec +9 -12
  11. data/lib/active_job/retry.rb +69 -90
  12. data/lib/active_job/retry/constant_backoff_strategy.rb +50 -0
  13. data/lib/active_job/retry/constant_options_validator.rb +76 -0
  14. data/lib/active_job/retry/deserialize_monkey_patch.rb +17 -12
  15. data/lib/active_job/retry/errors.rb +6 -0
  16. data/lib/active_job/retry/variable_backoff_strategy.rb +34 -0
  17. data/lib/active_job/retry/variable_options_validator.rb +56 -0
  18. data/lib/active_job/retry/version.rb +1 -1
  19. data/spec/retry/constant_backoff_strategy_spec.rb +115 -0
  20. data/spec/retry/constant_options_validator_spec.rb +81 -0
  21. data/spec/retry/variable_backoff_strategy_spec.rb +121 -0
  22. data/spec/retry/variable_options_validator_spec.rb +83 -0
  23. data/spec/retry_spec.rb +114 -170
  24. data/spec/spec_helper.rb +3 -2
  25. data/test/adapters/backburner.rb +3 -0
  26. data/test/adapters/delayed_job.rb +7 -0
  27. data/test/adapters/inline.rb +1 -0
  28. data/test/adapters/qu.rb +3 -0
  29. data/test/adapters/que.rb +4 -0
  30. data/test/adapters/queue_classic.rb +2 -0
  31. data/test/adapters/resque.rb +2 -0
  32. data/test/adapters/sidekiq.rb +2 -0
  33. data/test/adapters/sneakers.rb +2 -0
  34. data/test/adapters/sucker_punch.rb +2 -0
  35. data/test/cases/adapter_test.rb +8 -0
  36. data/test/cases/argument_serialization_test.rb +84 -0
  37. data/test/cases/callbacks_test.rb +23 -0
  38. data/test/cases/job_serialization_test.rb +15 -0
  39. data/test/cases/logging_test.rb +114 -0
  40. data/test/cases/queue_naming_test.rb +102 -0
  41. data/test/cases/queuing_test.rb +44 -0
  42. data/test/cases/rescue_test.rb +35 -0
  43. data/test/cases/test_case_test.rb +14 -0
  44. data/test/cases/test_helper_test.rb +226 -0
  45. data/test/helper.rb +21 -0
  46. data/test/integration/queuing_test.rb +46 -0
  47. data/test/jobs/callback_job.rb +31 -0
  48. data/test/jobs/gid_job.rb +10 -0
  49. data/test/jobs/hello_job.rb +9 -0
  50. data/test/jobs/logging_job.rb +12 -0
  51. data/test/jobs/nested_job.rb +12 -0
  52. data/test/jobs/rescue_job.rb +31 -0
  53. data/test/models/person.rb +20 -0
  54. data/test/support/backburner/inline.rb +8 -0
  55. data/test/support/delayed_job/delayed/backend/test.rb +111 -0
  56. data/test/support/delayed_job/delayed/serialization/test.rb +0 -0
  57. data/test/support/integration/adapters/backburner.rb +38 -0
  58. data/test/support/integration/adapters/delayed_job.rb +20 -0
  59. data/test/support/integration/adapters/que.rb +37 -0
  60. data/test/support/integration/adapters/resque.rb +49 -0
  61. data/test/support/integration/adapters/sidekiq.rb +58 -0
  62. data/test/support/integration/dummy_app_template.rb +28 -0
  63. data/test/support/integration/helper.rb +30 -0
  64. data/test/support/integration/jobs_manager.rb +27 -0
  65. data/test/support/integration/test_case_helpers.rb +48 -0
  66. data/test/support/job_buffer.rb +19 -0
  67. data/test/support/que/inline.rb +9 -0
  68. metadata +60 -12
  69. data/lib/active_job-retry.rb +0 -14
  70. data/lib/active_job/retry/exponential_backoff.rb +0 -92
  71. data/lib/active_job/retry/exponential_options_validator.rb +0 -57
  72. data/lib/active_job/retry/invalid_configuration_error.rb +0 -6
  73. data/lib/active_job/retry/options_validator.rb +0 -84
@@ -1,7 +1,4 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'active_job/retry/version'
1
+ require File.expand_path('../lib/active_job/retry/version', __FILE__)
5
2
 
6
3
  Gem::Specification.new do |s|
7
4
  s.name = 'activejob-retry'
@@ -9,24 +6,24 @@ Gem::Specification.new do |s|
9
6
  s.date = Date.today.strftime('%Y-%m-%d')
10
7
  s.authors = ['Isaac Seymour']
11
8
  s.email = ['isaac@isaacseymour.co.uk']
12
- s.summary = 'Automatic retrying DSL for ActiveJob'
9
+ s.summary = 'Automatic retry functionality for ActiveJob.'
13
10
  s.description = <<-EOL
14
- activejob-retry provides a simple DSL for automatically retrying ActiveJobs when they
15
- fail, with exponential backoff.
11
+ activejob-retry provides automatic retry functionality for failed
12
+ ActiveJobs, with exponential backoff.
16
13
 
17
14
  Features:
18
15
 
19
- * (Should) work with any queue adapter that supports retries.
16
+ * Works with any queue adapter that supports retries.
20
17
  * Whitelist/blacklist exceptions to retry on.
21
18
  * Exponential backoff (varying the delay between retries).
22
19
  * Light and easy to override retry logic.
23
20
  EOL
24
- s.homepage = 'http://github.com/isaacseymour/activejob-retry'
21
+ s.homepage = 'http://github.com/gocardless/activejob-retry'
25
22
  s.license = 'MIT'
26
23
 
27
24
  s.has_rdoc = false
28
- s.files = `git ls-files`.split($/)
29
- s.require_paths = %w[lib]
25
+ s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
26
+ s.require_paths = %w(lib)
30
27
 
31
28
  s.add_dependency('activejob', '>= 4.2')
32
29
  s.add_dependency('activesupport', '>= 4.2')
@@ -34,5 +31,5 @@ Gem::Specification.new do |s|
34
31
  s.add_development_dependency('rake', ' >= 10.3')
35
32
  s.add_development_dependency('rspec')
36
33
  s.add_development_dependency('rspec-its')
37
- s.add_development_dependency('pry-byebug')
34
+ s.add_development_dependency('rubocop')
38
35
  end
@@ -1,87 +1,73 @@
1
+ require 'active_job'
2
+ require 'active_support'
3
+ require 'active_support/core_ext' # ActiveJob uses core exts, but doesn't require it
4
+ require 'active_job/retry/version'
5
+ require 'active_job/retry/errors'
6
+ require 'active_job/retry/constant_backoff_strategy'
7
+ require 'active_job/retry/variable_backoff_strategy'
8
+
9
+ unless ActiveJob::Base.method_defined?(:deserialize)
10
+ require 'active_job/retry/deserialize_monkey_patch'
11
+ end
12
+
1
13
  module ActiveJob
2
14
  module Retry
3
- class UnsupportedAdapterError < StandardError; end
4
- SUPPORTED_ADAPTERS = %i(backburner delayed_job que resque sidekiq).freeze
5
-
6
- # If you want your job to retry on failure, simply include this module in your class,
7
- #
8
- # class DeliverWebHook < ActiveJob::Base
9
- # include ActiveJob::Retry
10
- # queue_as :web_hooks
11
- #
12
- # retry_with limit: 8, # default 1
13
- # delay: 60, # default 0
14
- # fatal_exceptions: [RuntimeError], # default [], i.e. none
15
- # retry_exceptions: [TimeoutError] # default nil, i.e. all
16
- #
17
- # def perform(url, web_hook_id, hmac_key)
18
- # work!
19
- # end
20
- # end
15
+ SUPPORTED_ADAPTERS = [
16
+ 'ActiveJob::QueueAdapters::BackburnerAdapter',
17
+ 'ActiveJob::QueueAdapters::DelayedJobAdapter',
18
+ 'ActiveJob::QueueAdapters::ResqueAdapter',
19
+ 'ActiveJob::QueueAdapters::QueAdapter'
20
+ ].freeze
21
+
21
22
  def self.included(base)
22
- # This breaks all specs because the adapter gets set after class eval :(
23
- # unless SUPPORTED_ADAPTERS.include?(ActiveJob::Base.queue_adapter)
24
- # raise UnsupportedAdapterError,
25
- # "Only Backburner, DelayedJob, Que, Resque, and Sidekiq support delayed " \
26
- # "retries. #{ActiveJob::Base.queue_adapter} is not supported."
27
- # end
23
+ unless SUPPORTED_ADAPTERS.include?(ActiveJob::Base.queue_adapter.to_s)
24
+ raise UnsupportedAdapterError,
25
+ 'Only Backburner, DelayedJob, Que, and Resque support delayed ' \
26
+ "retries. #{ActiveJob::Base.queue_adapter} is not supported."
27
+ end
28
28
 
29
29
  base.extend(ClassMethods)
30
30
  end
31
31
 
32
- module ClassMethods
33
- # Setup DSL
34
- def retry_with(options)
35
- OptionsValidator.new(options).validate!
36
-
37
- @retry_limit = options[:limit] if options[:limit]
38
- @retry_delay = options[:delay] if options[:delay]
39
- @fatal_exceptions = options[:fatal_exceptions] if options[:fatal_exceptions]
40
- @retry_exceptions = options[:retry_exceptions] if options[:retry_exceptions]
41
- end
32
+ #################
33
+ # Configuration #
34
+ #################
42
35
 
43
- ############
44
- # Defaults #
45
- ############
46
- def retry_limit
47
- @retry_limit ||= 1
48
- end
49
-
50
- def retry_delay
51
- @retry_delay ||= 0
52
- end
36
+ module ClassMethods
37
+ attr_reader :backoff_strategy
53
38
 
54
- def fatal_exceptions
55
- @fatal_exceptions ||= []
39
+ def constant_retry(options)
40
+ retry_with(ConstantBackoffStrategy.new(options))
56
41
  end
57
42
 
58
- def retry_exceptions
59
- @retry_exceptions ||= nil
43
+ def variable_retry(options)
44
+ retry_with(VariableBackoffStrategy.new(options))
60
45
  end
61
46
 
62
- #################
63
- # Retry helpers #
64
- #################
65
- def retry_exception?(exception)
66
- return true if retry_exceptions.nil? && fatal_exceptions.empty?
67
- return exception_whitelisted?(exception) unless retry_exceptions.nil?
68
- !exception_blacklisted?(exception)
69
- end
47
+ def retry_with(backoff_strategy)
48
+ unless backoff_strategy_valid?(backoff_strategy)
49
+ raise InvalidConfigurationError,
50
+ 'Backoff strategies must define `should_retry?(attempt, exception)`, ' \
51
+ 'and `retry_delay(attempt, exception)`.'
52
+ end
70
53
 
71
- def exception_whitelisted?(exception)
72
- retry_exceptions.any? { |ex| exception.is_a?(ex) }
54
+ @backoff_strategy = backoff_strategy
73
55
  end
74
56
 
75
- def exception_blacklisted?(exception)
76
- fatal_exceptions.any? { |ex| exception.is_a?(ex) }
57
+ def backoff_strategy_valid?(backoff_strategy)
58
+ backoff_strategy.respond_to?(:should_retry?) &&
59
+ backoff_strategy.respond_to?(:retry_delay) &&
60
+ backoff_strategy.method(:should_retry?).arity == 2 &&
61
+ backoff_strategy.method(:retry_delay).arity == 2
77
62
  end
78
63
  end
79
64
 
80
- #############
81
- # Overrides #
82
- #############
65
+ #############################
66
+ # Storage of attempt number #
67
+ #############################
68
+
83
69
  def serialize
84
- super.merge('retry_attempt' => retry_attempt + 1)
70
+ super.merge('retry_attempt' => retry_attempt)
85
71
  end
86
72
 
87
73
  def deserialize(job_data)
@@ -89,42 +75,35 @@ module ActiveJob
89
75
  @retry_attempt = job_data['retry_attempt']
90
76
  end
91
77
 
92
- # Override `rescue_with_handler` to make sure our catch is the last one, and doesn't
93
- # happen if the exception has already been caught in a `rescue_from`
94
- def rescue_with_handler(exception)
95
- super || retry_or_reraise(exception)
96
- end
97
-
98
- ##################
99
- # Retrying logic #
100
- ##################
101
78
  def retry_attempt
102
- @retry_attempt ||= 0
79
+ @retry_attempt ||= 1
103
80
  end
104
81
 
105
- # Override me if you want more complex behaviour
106
- def retry_delay
107
- self.class.retry_delay
108
- end
82
+ ##########################
83
+ # Performing the retries #
84
+ ##########################
109
85
 
110
- def should_retry?(exception)
111
- return false if retry_limit_reached?
112
- return false unless self.class.retry_exception?(exception)
113
- true
86
+ # Override `rescue_with_handler` to make sure our catch is the last one, and doesn't
87
+ # happen if the exception has already been caught in a `rescue_from`
88
+ def rescue_with_handler(exception)
89
+ super || retry_or_reraise(exception)
114
90
  end
115
91
 
116
- def retry_limit_reached?
117
- return true if self.class.retry_limit == 0
118
- return false if self.class.retry_limit == -1
119
- retry_attempt >= self.class.retry_limit
120
- end
92
+ private
121
93
 
122
94
  def retry_or_reraise(exception)
123
- raise exception unless should_retry?(exception)
95
+ unless self.class.backoff_strategy.should_retry?(retry_attempt, exception)
96
+ raise exception
97
+ end
124
98
 
125
- this_delay = retry_delay
126
- logger.log(Logger::INFO, "Retrying (attempt #{retry_attempt + 1}, waiting #{this_delay}s)")
99
+ this_delay = self.class.backoff_strategy.retry_delay(retry_attempt, exception)
100
+ # TODO: This breaks DelayedJob and Resque for some weird ActiveSupport reason.
101
+ # logger.log(Logger::INFO,
102
+ # "Retrying (attempt #{retry_attempt + 1}, waiting #{this_delay}s)")
103
+ @retry_attempt += 1
127
104
  retry_job(wait: this_delay)
105
+
106
+ true # Exception has been handled
128
107
  end
129
108
  end
130
109
  end
@@ -0,0 +1,50 @@
1
+ require 'active_job/retry/constant_options_validator'
2
+
3
+ module ActiveJob
4
+ module Retry
5
+ class ConstantBackoffStrategy
6
+ def initialize(options)
7
+ ConstantOptionsValidator.new(options).validate!
8
+ @retry_limit = options.fetch(:limit, 1)
9
+ @retry_delay = options.fetch(:delay, 0)
10
+ @fatal_exceptions = options.fetch(:fatal_exceptions, [])
11
+ @retry_exceptions = options.fetch(:retry_exceptions, nil)
12
+ end
13
+
14
+ def should_retry?(attempt, exception)
15
+ return false if retry_limit_reached?(attempt)
16
+ return false unless retry_exception?(exception)
17
+ true
18
+ end
19
+
20
+ def retry_delay(_attempt, _exception)
21
+ @retry_delay
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :retry_limit, :fatal_exceptions, :retry_exceptions
27
+
28
+ def retry_limit_reached?(attempt)
29
+ return false unless retry_limit
30
+ attempt >= retry_limit
31
+ end
32
+
33
+ def retry_exception?(exception)
34
+ if retry_exceptions.nil?
35
+ !exception_blacklisted?(exception)
36
+ else
37
+ exception_whitelisted?(exception)
38
+ end
39
+ end
40
+
41
+ def exception_whitelisted?(exception)
42
+ retry_exceptions.any? { |ex| exception.is_a?(ex) }
43
+ end
44
+
45
+ def exception_blacklisted?(exception)
46
+ fatal_exceptions.any? { |ex| exception.is_a?(ex) }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,76 @@
1
+ require 'active_job/retry/errors'
2
+
3
+ module ActiveJob
4
+ module Retry
5
+ class ConstantOptionsValidator
6
+ def initialize(options)
7
+ @options = options
8
+ end
9
+
10
+ def validate!
11
+ validate_limit_numericality!
12
+ validate_infinite_limit!
13
+ validate_delay!
14
+ validate_not_both_exceptions!
15
+ # Fatal exceptions must be an array (cannot be nil, since then all exceptions
16
+ # would be fatal - for that just set `limit: 0`)
17
+ validate_array_of_exceptions!(:fatal_exceptions)
18
+ # Retry exceptions must be an array of exceptions or `nil` to retry any exception
19
+ validate_array_of_exceptions!(:retry_exceptions) if options[:retry_exceptions]
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :options
25
+
26
+ # Limit must be an integer >= 0, or nil
27
+ def validate_limit_numericality!
28
+ return unless options[:limit]
29
+ return if options[:limit].is_a?(Fixnum) && options[:limit] >= 0
30
+
31
+ raise InvalidConfigurationError,
32
+ 'Limit must be an integer >= 0, or nil for unlimited retries'
33
+ end
34
+
35
+ # If no limit is supplied, you *must* set `unlimited_retries: true` and
36
+ # understand that your ops team might hurt you.
37
+ def validate_infinite_limit!
38
+ limit = options.fetch(:limit, 1)
39
+ return unless limit.nil? ^ options[:unlimited_retries] == true
40
+
41
+ if limit.nil? && options[:unlimited_retries] != true
42
+ raise InvalidConfigurationError,
43
+ 'You must set `unlimited_retries: true` to use `limit: nil`'
44
+ else
45
+ raise InvalidConfigurationError,
46
+ 'You must set `limit: nil` to have unlimited retries'
47
+ end
48
+ end
49
+
50
+ # Delay must be non-negative
51
+ def validate_delay!
52
+ return unless options[:delay]
53
+ return if options[:delay] >= 0
54
+
55
+ raise InvalidConfigurationError, 'Delay must be non-negative'
56
+ end
57
+
58
+ def validate_not_both_exceptions!
59
+ return unless options[:fatal_exceptions] && options[:retry_exceptions]
60
+
61
+ raise InvalidConfigurationError,
62
+ 'fatal_exceptions and retry_exceptions cannot be used together'
63
+ end
64
+
65
+ def validate_array_of_exceptions!(key)
66
+ return unless options[key]
67
+ if options[key].is_a?(Array) &&
68
+ options[key].all? { |ex| ex.is_a?(Class) && ex <= Exception }
69
+ return
70
+ end
71
+
72
+ raise InvalidConfigurationError, "#{key} must be an array of exceptions!"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -4,19 +4,24 @@
4
4
  # of the deserialization as possible to the instance, i.e. `ActiveJob::Base#deserialize`,
5
5
  # which can be overridden. This allows us to store extra information in the queue (i.e.
6
6
  # retry_attempt), which is essential for ActiveJob::Retry.
7
+ #
8
+ # This monkey patch is automatically applied if necessary when ActiveJob::Retry is
9
+ # required.
7
10
 
8
- raise "Unnecessary monkey patch!" if ActiveJob::Base.method_defined?(:deserialize)
11
+ raise 'Unnecessary monkey patch!' if ActiveJob::Base.method_defined?(:deserialize)
9
12
 
10
- class ActiveJob::Base
11
- def self.deserialize(job_data)
12
- job = job_data['job_class'].constantize.new
13
- job.deserialize(job_data)
14
- job
15
- end
13
+ module ActiveJob
14
+ class Base
15
+ def self.deserialize(job_data)
16
+ job = job_data['job_class'].constantize.new
17
+ job.deserialize(job_data)
18
+ job
19
+ end
16
20
 
17
- def deserialize(job_data)
18
- self.job_id = job_data['job_id']
19
- self.queue_name = job_data['queue_name']
20
- self.serialized_arguments = job_data['arguments']
21
+ def deserialize(job_data)
22
+ self.job_id = job_data['job_id']
23
+ self.queue_name = job_data['queue_name']
24
+ self.serialized_arguments = job_data['arguments']
25
+ end
21
26
  end
22
- end
27
+ end
@@ -0,0 +1,6 @@
1
+ module ActiveJob
2
+ module Retry
3
+ class InvalidConfigurationError < StandardError; end
4
+ class UnsupportedAdapterError < StandardError; end
5
+ end
6
+ end
@@ -0,0 +1,34 @@
1
+ require 'active_job/retry/constant_backoff_strategy'
2
+ require 'active_job/retry/variable_options_validator'
3
+
4
+ module ActiveJob
5
+ module Retry
6
+ class VariableBackoffStrategy < ConstantBackoffStrategy
7
+ def initialize(options)
8
+ super(options)
9
+ VariableOptionsValidator.new(options).validate!
10
+ @retry_limit = options.fetch(:delays).length + 1
11
+ @retry_delays = options.fetch(:delays)
12
+ @min_delay_multiplier = options.fetch(:min_delay_multiplier, 1.0)
13
+ @max_delay_multiplier = options.fetch(:max_delay_multiplier, 1.0)
14
+ end
15
+
16
+ def retry_delay(attempt, _exception)
17
+ (retry_delays[attempt - 1] * delay_multiplier).to_i
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :retry_delays, :min_delay_multiplier, :max_delay_multiplier
23
+
24
+ def random_delay?
25
+ min_delay_multiplier != max_delay_multiplier
26
+ end
27
+
28
+ def delay_multiplier
29
+ return max_delay_multiplier unless random_delay?
30
+ rand(min_delay_multiplier..max_delay_multiplier)
31
+ end
32
+ end
33
+ end
34
+ end