onyx-resque-retry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ <% timestamp = params[:timestamp].to_i %>
2
+
3
+ <h1>
4
+ Delayed Jobs scheduled for <%= format_time(Time.at(timestamp)) %>
5
+ (with Retry Information)
6
+ </h1>
7
+
8
+ <p class="intro">
9
+ This list below contains the delayed jobs scheduled for the current
10
+ timestamp, with retry information.
11
+ </p>
12
+
13
+ <p class="sub">
14
+ Showing <%= start = params[:start].to_i %> to <%= start + 20 %> of
15
+ <b><%= size = resque.delayed_timestamp_size(timestamp) %></b> jobs
16
+ </p>
17
+
18
+ <table class="jobs">
19
+ <tr>
20
+ <th>Class</th>
21
+ <th>Args</th>
22
+ <th>Retry Attempts</th>
23
+ <th>Exception</th>
24
+ <th>Backtrace</th>
25
+ </tr>
26
+ <% jobs = resque.delayed_timestamp_peek(timestamp, start, 20) %>
27
+ <% jobs.each do |job| %>
28
+ <% retry_key = retry_key_for_job(job) %>
29
+ <% retry_attempts = retry_attempts_for_job(job) %>
30
+ <tr>
31
+ <td class="class"><%= h job['class'] %></td>
32
+ <td class="args"><%= h job['args'].inspect %></td>
33
+ <% if retry_attempts.nil? %>
34
+ <td colspan="3"><i>n/a - normal delayed job</i></td>
35
+ <% else %>
36
+ <td><%= retry_attempts %></td>
37
+ <% failure = retry_failure_details(retry_key) %>
38
+ <td><code><%= failure['exception'] %></code></td>
39
+ <td class="error">
40
+ <% if failure['backtrace'] %>
41
+ <a href="#" class="backtrace"><%= h(failure['error']) %></a>
42
+ <pre style="display:none"><%= h failure['backtrace'].join("\n") %></pre>
43
+ <% else %>
44
+ <%= h failure['error'] %>
45
+ <% end %>
46
+ </td>
47
+ <% end %>
48
+ </tr>
49
+ <% end %>
50
+ <% if jobs.empty? %>
51
+ <tr>
52
+ <td class="no-data" colspan="5">
53
+ There are no pending jobs scheduled for this time.
54
+ </td>
55
+ </tr>
56
+ <% end %>
57
+ </table>
58
+
59
+ <%= partial :next_more, :start => start, :size => size %>
@@ -0,0 +1,93 @@
1
+ require 'resque/failure/multiple'
2
+
3
+ module Resque
4
+ module Failure
5
+
6
+ # A multiple failure backend, with retry suppression.
7
+ #
8
+ # For example: if you had a job that could retry 5 times, your failure
9
+ # backends are not notified unless the _final_ retry attempt also fails.
10
+ #
11
+ # Example:
12
+ #
13
+ # require 'resque-retry'
14
+ # require 'resque/failure/redis'
15
+ #
16
+ # Resque::Failure::MultipleWithRetrySuppression.classes = [Resque::Failure::Redis]
17
+ # Resque::Failure.backend = Resque::Failure::MultipleWithRetrySuppression
18
+ #
19
+ class MultipleWithRetrySuppression < Multiple
20
+ include Resque::Helpers
21
+
22
+ module CleanupHooks
23
+ # Resque after_perform hook.
24
+ #
25
+ # Deletes retry failure information from Redis.
26
+ def after_perform_retry_failure_cleanup(*args)
27
+ retry_key = redis_retry_key(*args)
28
+ failure_key = Resque::Failure::MultipleWithRetrySuppression.failure_key(retry_key)
29
+ Resque.redis.del(failure_key)
30
+ end
31
+ end
32
+
33
+ # Called when the job fails.
34
+ #
35
+ # If the job will retry, suppress the failure from the other backends.
36
+ # Store the lastest failure information in redis, used by the web
37
+ # interface.
38
+ def save
39
+ unless retryable? && retrying?
40
+ cleanup_retry_failure_log!
41
+ super
42
+ else
43
+ data = {
44
+ :failed_at => Time.now.strftime("%Y/%m/%d %H:%M:%S"),
45
+ :payload => payload,
46
+ :exception => exception.class.to_s,
47
+ :error => exception.to_s,
48
+ :backtrace => Array(exception.backtrace),
49
+ :worker => worker.to_s,
50
+ :queue => queue
51
+ }
52
+
53
+ # Register cleanup hooks.
54
+ unless klass.respond_to?(:after_perform_retry_failure_cleanup)
55
+ klass.send(:extend, CleanupHooks)
56
+ end
57
+
58
+ redis[failure_key] = Resque.encode(data)
59
+ end
60
+ end
61
+
62
+ # Expose this for the hook's use.
63
+ def self.failure_key(retry_key)
64
+ 'failure_' + retry_key
65
+ end
66
+
67
+ protected
68
+ def klass
69
+ constantize(payload['class'])
70
+ end
71
+
72
+ def retry_key
73
+ klass.redis_retry_key(payload['args'])
74
+ end
75
+
76
+ def failure_key
77
+ self.class.failure_key(retry_key)
78
+ end
79
+
80
+ def retryable?
81
+ klass.respond_to?(:redis_retry_key)
82
+ end
83
+
84
+ def retrying?
85
+ redis.exists(retry_key)
86
+ end
87
+
88
+ def cleanup_retry_failure_log!
89
+ redis.del(failure_key) if retryable?
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,64 @@
1
+ module Resque
2
+ module Plugins
3
+
4
+ # If you want your job to retry on failure using a varying delay, simply
5
+ # extend your module/class with this module:
6
+ #
7
+ # class DeliverSMS
8
+ # extend Resque::Plugins::ExponentialBackoff
9
+ # @queue = :mt_messages
10
+ #
11
+ # def self.perform(mt_id, mobile_number, message)
12
+ # heavy_lifting
13
+ # end
14
+ # end
15
+ #
16
+ # Easily do something custom:
17
+ #
18
+ # class DeliverSMS
19
+ # extend Resque::Plugins::ExponentialBackoff
20
+ # @queue = :mt_messages
21
+ #
22
+ # @retry_limit = 4
23
+ #
24
+ # # retry delay in seconds; [0] => 1st retry, [1] => 2nd..4th retry.
25
+ # @backoff_strategy = [0, 60]
26
+ #
27
+ # # used to build redis key, for counting job attempts.
28
+ # def self.identifier(mt_id, mobile_number, message)
29
+ # "#{mobile_number}:#{mt_id}"
30
+ # end
31
+ #
32
+ # self.perform(mt_id, mobile_number, message)
33
+ # heavy_lifting
34
+ # end
35
+ # end
36
+ #
37
+ module ExponentialBackoff
38
+ include Resque::Plugins::Retry
39
+
40
+ # Defaults to the number of delays in the backoff strategy.
41
+ #
42
+ # @return [Number] maximum number of retries
43
+ def retry_limit
44
+ @retry_limit ||= backoff_strategy.length
45
+ end
46
+
47
+ # Selects the delay from the backoff strategy.
48
+ #
49
+ # @return [Number] seconds to delay until the next retry.
50
+ def retry_delay
51
+ backoff_strategy[retry_attempt] || backoff_strategy.last
52
+ end
53
+
54
+ # @abstract
55
+ # The backoff strategy is used to vary the delay between retry attempts.
56
+ #
57
+ # @return [Array] array of delays. index = retry attempt
58
+ def backoff_strategy
59
+ @backoff_strategy ||= [0, 60, 600, 3600, 10_800, 21_600]
60
+ end
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,221 @@
1
+ module Resque
2
+ module Plugins
3
+
4
+ # If you want your job to retry on failure, simply extend your module/class
5
+ # with this module:
6
+ #
7
+ # class DeliverWebHook
8
+ # extend Resque::Plugins::Retry # allows 1 retry by default.
9
+ # @queue = :web_hooks
10
+ #
11
+ # def self.perform(url, hook_id, hmac_key)
12
+ # heavy_lifting
13
+ # end
14
+ # end
15
+ #
16
+ # Easily do something custom:
17
+ #
18
+ # class DeliverWebHook
19
+ # extend Resque::Plugins::Retry
20
+ # @queue = :web_hooks
21
+ #
22
+ # @retry_limit = 8 # default: 1
23
+ # @retry_delay = 60 # default: 0
24
+ #
25
+ # # used to build redis key, for counting job attempts.
26
+ # def self.identifier(url, hook_id, hmac_key)
27
+ # "#{url}-#{hook_id}"
28
+ # end
29
+ #
30
+ # def self.perform(url, hook_id, hmac_key)
31
+ # heavy_lifting
32
+ # end
33
+ # end
34
+ #
35
+ module Retry
36
+
37
+ # Copy retry criteria checks on inheritance.
38
+ def inherited(subclass)
39
+ super(subclass)
40
+ subclass.instance_variable_set("@retry_criteria_checks", retry_criteria_checks.dup)
41
+ end
42
+
43
+ # @abstract You may override to implement a custom identifier,
44
+ # you should consider doing this if your job arguments
45
+ # are many/long or may not cleanly cleanly to strings.
46
+ #
47
+ # Builds an identifier using the job arguments. This identifier
48
+ # is used as part of the redis key.
49
+ #
50
+ # @param [Array] args job arguments
51
+ # @return [String] job identifier
52
+ def identifier(*args)
53
+ args_string = args.join('-')
54
+ args_string.empty? ? nil : args_string
55
+ end
56
+
57
+ # Builds the redis key to be used for keeping state of the job
58
+ # attempts.
59
+ #
60
+ # @return [String] redis key
61
+ def redis_retry_key(*args)
62
+ ['resque-retry', name, identifier(*args)].compact.join(":").gsub(/\s/, '')
63
+ end
64
+
65
+ # Maximum number of retrys we can attempt to successfully perform the job.
66
+ # A retry limit of 0 or below will retry forever.
67
+ #
68
+ # @return [Fixnum]
69
+ def retry_limit
70
+ @retry_limit ||= 1
71
+ end
72
+
73
+ # Number of retry attempts used to try and perform the job.
74
+ #
75
+ # The real value is kept in Redis, it is accessed and incremented using
76
+ # a before_perform hook.
77
+ #
78
+ # @return [Fixnum] number of attempts
79
+ def retry_attempt
80
+ @retry_attempt ||= 0
81
+ end
82
+
83
+ # @abstract
84
+ # Number of seconds to delay until the job is retried.
85
+ #
86
+ # @return [Number] number of seconds to delay
87
+ def retry_delay
88
+ @retry_delay ||= 0
89
+ end
90
+
91
+ # @abstract
92
+ # Modify the arguments used to retry the job. Use this to do something
93
+ # other than try the exact same job again.
94
+ #
95
+ # @return [Array] new job arguments
96
+ def args_for_retry(*args)
97
+ args
98
+ end
99
+
100
+ # Convenience method to test whether you may retry on a given exception.
101
+ #
102
+ # @return [Boolean]
103
+ def retry_exception?(exception)
104
+ return true if retry_exceptions.nil?
105
+ !! retry_exceptions.any? { |ex| ex >= exception }
106
+ end
107
+
108
+ # @abstract
109
+ # Controls what exceptions may be retried.
110
+ #
111
+ # Default: `nil` - this will retry all exceptions.
112
+ #
113
+ # @return [Array, nil]
114
+ def retry_exceptions
115
+ @retry_exceptions ||= nil
116
+ end
117
+
118
+ # Test if the retry criteria is valid.
119
+ #
120
+ # @param [Exception] exception
121
+ # @param [Array] args job arguments
122
+ # @return [Boolean]
123
+ def retry_criteria_valid?(exception, *args)
124
+ # if the retry limit was reached, dont bother checking anything else.
125
+ return false if retry_limit_reached?
126
+
127
+ # We always want to retry if the exception matches.
128
+ should_retry = retry_exception?(exception.class)
129
+
130
+ # call user retry criteria check blocks.
131
+ retry_criteria_checks.each do |criteria_check|
132
+ should_retry ||= !!criteria_check.call(exception, *args)
133
+ end
134
+
135
+ should_retry
136
+ end
137
+
138
+ # Retry criteria checks.
139
+ #
140
+ # @return [Array]
141
+ def retry_criteria_checks
142
+ @retry_criteria_checks ||= []
143
+ @retry_criteria_checks
144
+ end
145
+
146
+ # Test if the retry limit has been reached.
147
+ #
148
+ # @return [Boolean]
149
+ def retry_limit_reached?
150
+ if retry_limit > 0
151
+ return true if retry_attempt >= retry_limit
152
+ end
153
+ false
154
+ end
155
+
156
+ # Register a retry criteria check callback to be run before retrying
157
+ # the job again.
158
+ #
159
+ # If any callback returns `true`, the job will be retried.
160
+ #
161
+ # @example Using a custom retry criteria check.
162
+ #
163
+ # retry_criteria_check do |exception, *args|
164
+ # if exception.message =~ /InvalidJobId/
165
+ # # don't retry if we got passed a invalid job id.
166
+ # false
167
+ # else
168
+ # true
169
+ # end
170
+ # end
171
+ #
172
+ # @yield [exception, *args]
173
+ # @yieldparam exception [Exception] the exception that was raised
174
+ # @yieldparam args [Array] job arguments
175
+ # @yieldreturn [Boolean] false == dont retry, true = can retry
176
+ def retry_criteria_check(&block)
177
+ retry_criteria_checks << block
178
+ end
179
+
180
+ # Will retry the job.
181
+ def try_again(*args)
182
+ @retry_job_class ||= self
183
+ if retry_delay <= 0
184
+ # If the delay is 0, no point passing it through the scheduler
185
+ Resque.enqueue(@retry_job_class, *args_for_retry(*args))
186
+ else
187
+ Resque.enqueue_in(retry_delay, @retry_job_class, *args_for_retry(*args))
188
+ end
189
+ end
190
+
191
+ # Resque before_perform hook.
192
+ #
193
+ # Increments and sets the `@retry_attempt` count.
194
+ def before_perform_retry(*args)
195
+ retry_key = redis_retry_key(*args)
196
+ Resque.redis.setnx(retry_key, -1) # default to -1 if not set.
197
+ @retry_attempt = Resque.redis.incr(retry_key) # increment by 1.
198
+ end
199
+
200
+ # Resque after_perform hook.
201
+ #
202
+ # Deletes retry attempt count from Redis.
203
+ def after_perform_retry(*args)
204
+ Resque.redis.del(redis_retry_key(*args))
205
+ end
206
+
207
+ # Resque on_failure hook.
208
+ #
209
+ # Checks if our retry criteria is valid, if it is we try again.
210
+ # Otherwise the retry attempt count is deleted from Redis.
211
+ def on_failure_retry(exception, *args)
212
+ if retry_criteria_valid?(exception, *args)
213
+ try_again(*args)
214
+ else
215
+ Resque.redis.del(redis_retry_key(*args))
216
+ end
217
+ end
218
+
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,62 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ class ExponentialBackoffTest < Test::Unit::TestCase
4
+ def setup
5
+ Resque.redis.flushall
6
+ @worker = Resque::Worker.new(:testing)
7
+ @worker.register_worker
8
+ end
9
+
10
+ def test_resque_plugin_lint
11
+ assert_nothing_raised do
12
+ Resque::Plugin.lint(Resque::Plugins::ExponentialBackoff)
13
+ end
14
+ end
15
+
16
+ def test_default_backoff_strategy
17
+ now = Time.now
18
+ Resque.enqueue(ExponentialBackoffJob)
19
+
20
+ perform_next_job @worker
21
+ assert_equal 1, Resque.info[:processed], '1 processed job'
22
+ assert_equal 1, Resque.info[:failed], 'first ever run, and it should of failed, but never retried'
23
+ assert_equal 1, Resque.info[:pending], '1 pending job, because it never hits the scheduler'
24
+
25
+ perform_next_job @worker
26
+ assert_equal 2, Resque.info[:processed], '2nd run, but first retry'
27
+ assert_equal 2, Resque.info[:failed], 'should of failed again, this is the first retry attempt'
28
+ assert_equal 0, Resque.info[:pending], '0 pending jobs, it should be in the delayed queue'
29
+
30
+ delayed = Resque.delayed_queue_peek(0, 1)
31
+ assert_equal now.to_i + 60, delayed[0], '2nd delay' # the first had a zero delay.
32
+
33
+ 5.times do
34
+ Resque.enqueue(ExponentialBackoffJob)
35
+ perform_next_job @worker
36
+ end
37
+
38
+ delayed = Resque.delayed_queue_peek(0, 5)
39
+ assert_equal now.to_i + 600, delayed[1], '3rd delay'
40
+ assert_equal now.to_i + 3600, delayed[2], '4th delay'
41
+ assert_equal now.to_i + 10_800, delayed[3], '5th delay'
42
+ assert_equal now.to_i + 21_600, delayed[4], '6th delay'
43
+ end
44
+
45
+ def test_custom_backoff_strategy
46
+ now = Time.now
47
+ 4.times do
48
+ Resque.enqueue(CustomExponentialBackoffJob, 'http://lividpenguin.com', 1305, 'cd8079192d379dc612f17c660591a6cfb05f1dda')
49
+ perform_next_job @worker
50
+ end
51
+
52
+ delayed = Resque.delayed_queue_peek(0, 3)
53
+ assert_equal now.to_i + 10, delayed[0], '1st delay'
54
+ assert_equal now.to_i + 20, delayed[1], '2nd delay'
55
+ assert_equal now.to_i + 30, delayed[2], '3rd delay'
56
+ assert_equal 2, Resque.delayed_timestamp_size(delayed[2]), '4th delay should share delay with 3rd'
57
+
58
+ assert_equal 4, Resque.info[:processed], 'processed jobs'
59
+ assert_equal 4, Resque.info[:failed], 'failed jobs'
60
+ assert_equal 0, Resque.info[:pending], 'pending jobs'
61
+ end
62
+ end