resque-pertry 1.0.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.
- data/.gitignore +2 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +190 -0
- data/lib/resque/pertry.rb +7 -0
- data/lib/resque/pertry/migrations/create_resque_pertry_table.rb +30 -0
- data/lib/resque/pertry/persistence.rb +67 -0
- data/lib/resque/pertry/purger.rb +214 -0
- data/lib/resque/pertry/resque_pertry_persistence.rb +87 -0
- data/lib/resque/pertry/retry.rb +164 -0
- data/lib/resque/pertry/tasks.rb +22 -0
- data/lib/resque/plugins/pertry.rb +115 -0
- data/resque-pertry.gemspec +21 -0
- data/tasks/resque_pertry.rake +2 -0
- metadata +187 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) Anthony 'YoGiN' Powles
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
resque-pertry
|
2
|
+
=============
|
3
|
+
|
4
|
+
Setup
|
5
|
+
-----
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
gem 'resque-pertry'
|
9
|
+
```
|
10
|
+
|
11
|
+
Wherever you include resque's tasks, you should add:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
require 'resque/tasks'
|
15
|
+
require 'resque/pertry/tasks'
|
16
|
+
```
|
17
|
+
|
18
|
+
You'll need to run a migration to create the persistence table.
|
19
|
+
|
20
|
+
```ruby
|
21
|
+
require 'resque/pertry/migrations/create_resque_pertry_table'
|
22
|
+
|
23
|
+
class AddResquePertry < Resque::Pertry::Migrations::CreateResquePertryTable
|
24
|
+
# if you are using db-charmer, you can easily specify the connection to use
|
25
|
+
db_magic :connection => :resque
|
26
|
+
end
|
27
|
+
```
|
28
|
+
|
29
|
+
The associated model is `Resque::Pertry::ResquePertryPersistence`, feel free to add your own code in there if you need (such as db_magic)
|
30
|
+
|
31
|
+
You will also need to start [resque-scheduler](https://github.com/bvandenbos/resque-scheduler) and the resque pertry purger
|
32
|
+
|
33
|
+
```
|
34
|
+
$ VERBOSE=1 rake resque:scheduler
|
35
|
+
$ VERBOSE=1 rake resque:pertry:purger
|
36
|
+
```
|
37
|
+
|
38
|
+
The purger's default values will have it run every 5 minutes, and purge 100 failed jobs from redis and another 100 failed jobs from the database.
|
39
|
+
You can add hooks in your application if you want to process the purged jobs further (you may want to log the jobs you are purging):
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
Resque::Pertry::Purger.after_redis_purge do |job,redis|
|
43
|
+
# do something with this job from resque's failed queue
|
44
|
+
end
|
45
|
+
|
46
|
+
Resque::Pertry::Purger.after_database_purge do |job|
|
47
|
+
# do something with this job from the persistence table
|
48
|
+
end
|
49
|
+
```
|
50
|
+
|
51
|
+
Usage
|
52
|
+
-----
|
53
|
+
|
54
|
+
Your resque jobs need to inherit from `Resque::Plugins::Pertry` :
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
class SomeJob < Resque::Plugins::Pertry
|
58
|
+
|
59
|
+
# specify Resque queue
|
60
|
+
in_queue :critical
|
61
|
+
|
62
|
+
def perform
|
63
|
+
# do something
|
64
|
+
end
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
You can enqueue the job:
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
SomeJob.enqueue
|
72
|
+
```
|
73
|
+
|
74
|
+
You can pass arguments to your job:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
SomeJob.enqueue(:user_id => 42, :image_id => 24)
|
78
|
+
```
|
79
|
+
|
80
|
+
In your job class, you specify the arguments with `needs` :
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
class SomeJob < Resque::Plugins::Pertry
|
84
|
+
|
85
|
+
# specify Resque queue
|
86
|
+
in_queue :critical
|
87
|
+
|
88
|
+
needs :user_id, :image_id
|
89
|
+
|
90
|
+
def perform
|
91
|
+
# do something with @user_id and @image_id
|
92
|
+
end
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
The arguments `user_id` and `image_id` will then be accessible as instance variables `@user_id` and `@image_id`
|
97
|
+
You can also specify a default value for your needs:
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
needs :user_id, :image_id, :action => "delete"
|
101
|
+
```
|
102
|
+
|
103
|
+
Finally you can specify if your job should be persistent or not, and if it is, the properties to retry failed persistent jobs.
|
104
|
+
All jobs including `Resque::Plugins::Pertry` are persistent by default. Non persistent jobs act just like regular resque jobs.
|
105
|
+
|
106
|
+
To set a job as non persistent, simply:
|
107
|
+
|
108
|
+
```ruby
|
109
|
+
class SomeJob < Resque::Plugins::Pertry
|
110
|
+
|
111
|
+
# set job as non persistent, acts like a regular resque job
|
112
|
+
non_persistent
|
113
|
+
|
114
|
+
...
|
115
|
+
end
|
116
|
+
```
|
117
|
+
|
118
|
+
To retry a persistent job, you have several properties you can set:
|
119
|
+
|
120
|
+
* `set_retry_attempts <max_retries>`
|
121
|
+
|
122
|
+
Sets the maximum number of times we will retry a failed job.
|
123
|
+
The total number of times a job will run is max_retries + 1 (the original run).
|
124
|
+
|
125
|
+
* `set_retry_delay <seconds>`
|
126
|
+
|
127
|
+
Sets the number of seconds to wait before enqueueing a failed job again
|
128
|
+
|
129
|
+
* `set_retry_delays <seconds>, <seconds>, ...`
|
130
|
+
|
131
|
+
Set an array of delays.
|
132
|
+
The number of items will be used to defined `max_retries`, while each value will be used in turn for each consecutive retry delay.
|
133
|
+
If this is set, `set_retry_attempts` and `set_retry_delay` will be ignored
|
134
|
+
|
135
|
+
* `set_retry_exceptions <SomeException>, <SomeOtherException>, ...`
|
136
|
+
|
137
|
+
Sets a whitelist of exceptions you want to retry. Any other exceptions will not be retried.
|
138
|
+
If not set, all exceptions will be retried (default).
|
139
|
+
This property can be combined with any other property.
|
140
|
+
|
141
|
+
* `set_retry_ttl <seconds>`
|
142
|
+
|
143
|
+
Sets the maximum time to live for a job, after which the job will not be retried, even if it has attempts lefts.
|
144
|
+
This property can be combined with any other property.
|
145
|
+
|
146
|
+
The default settings for all persistent jobs are:
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
# retry a failed job once, enqueue the job immediately, job expires in 1 week
|
150
|
+
set_retry_attempts 1
|
151
|
+
set_retry_ttl 1.week
|
152
|
+
```
|
153
|
+
|
154
|
+
Here are some exemples of retry settings:
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
# retry 5 times, enqueue job immediately
|
158
|
+
set_retry_attempts 5
|
159
|
+
```
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
# retry 10 times, wait 1 minute before enqueueing the job again
|
163
|
+
set_retry_attempts 10
|
164
|
+
set_retry_delay 1.minute
|
165
|
+
```
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
# retry 3 times, wait time before enqueueing job increases after each attempt
|
169
|
+
set_retry_delays 1.minute, 5.minutes, 15.minutes
|
170
|
+
```
|
171
|
+
|
172
|
+
```ruby
|
173
|
+
# retry every 30 seconds for 1 hour, as long as we are getting the exception ActiveRecord::ConnectionTimeoutError
|
174
|
+
set_retry_ttl 1.hour
|
175
|
+
set_retry_delay 30.seconds
|
176
|
+
set_retry_exceptions ActiveRecord::ConnectionTimeoutError
|
177
|
+
```
|
178
|
+
|
179
|
+
TODO
|
180
|
+
----
|
181
|
+
|
182
|
+
Write some specs!
|
183
|
+
|
184
|
+
How to contribute
|
185
|
+
-----------------
|
186
|
+
|
187
|
+
Tell me what you think about this gem, things you like or don't about it, how it could be better.
|
188
|
+
Pull Requests most welcomed.
|
189
|
+
|
190
|
+
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Resque
|
2
|
+
module Pertry
|
3
|
+
module Migrations
|
4
|
+
class CreateResquePertryTable < ActiveRecord::Migration
|
5
|
+
|
6
|
+
def up
|
7
|
+
create_table :resque_pertry_persistence do |t|
|
8
|
+
t.string :audit_id, :null => false, :limit => 64
|
9
|
+
t.string :job, :null => false
|
10
|
+
t.text :arguments, :null => false, :limit => 64.kilobytes + 1
|
11
|
+
t.integer :attempt, :default => 1
|
12
|
+
t.datetime :enqueued_at
|
13
|
+
t.datetime :completed_at
|
14
|
+
t.datetime :failed_at
|
15
|
+
t.datetime :last_tried_at
|
16
|
+
t.datetime :expires_at
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
|
20
|
+
add_index :resque_pertry_persistence, :audit_id, :unique => true
|
21
|
+
end
|
22
|
+
|
23
|
+
def down
|
24
|
+
drop_table :resque_pertry_persistence
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module Resque
|
2
|
+
module Pertry
|
3
|
+
module Persistence
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
class_attribute :_persistent
|
8
|
+
|
9
|
+
# define all jobs as persistent by default
|
10
|
+
persistent
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
|
15
|
+
# Set job as persistent
|
16
|
+
def persistent
|
17
|
+
self._persistent = true
|
18
|
+
end
|
19
|
+
alias_method :durable, :persistent
|
20
|
+
|
21
|
+
def non_persistent
|
22
|
+
self._persistent = false
|
23
|
+
end
|
24
|
+
alias_method :non_durable, :non_persistent
|
25
|
+
|
26
|
+
# Check if job is persistent
|
27
|
+
def persistent?
|
28
|
+
!!self._persistent
|
29
|
+
end
|
30
|
+
|
31
|
+
# Resque before_enqueue hook
|
32
|
+
def before_enqueue_pertry_99_persistence(args = {})
|
33
|
+
pertry_key = Resque::Plugins::Pertry::JOB_HASH.to_s
|
34
|
+
|
35
|
+
args[pertry_key] ||= {}
|
36
|
+
args[pertry_key]['audit_id'] ||= UUIDTools::UUID.random_create.to_s
|
37
|
+
args[pertry_key]['queue_time'] ||= Time.now
|
38
|
+
args[pertry_key]['persist'] = persistent?
|
39
|
+
|
40
|
+
if persistent?
|
41
|
+
ResquePertryPersistence.create_job_if_needed(self, args)
|
42
|
+
end
|
43
|
+
|
44
|
+
# continue with enqueue
|
45
|
+
true
|
46
|
+
end
|
47
|
+
|
48
|
+
# Resque after_perform hook (job completed successfully)
|
49
|
+
def after_perform_pertry_00_persistence(args = {})
|
50
|
+
return unless persistent?
|
51
|
+
|
52
|
+
ResquePertryPersistence.finnish_job(self, args)
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
|
57
|
+
def audit_id
|
58
|
+
@_job_properties['audit_id']
|
59
|
+
end
|
60
|
+
|
61
|
+
def queue_time
|
62
|
+
Time.parse(@_job_properties['queue_time'])
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,214 @@
|
|
1
|
+
module Resque
|
2
|
+
module Pertry
|
3
|
+
class Purger
|
4
|
+
|
5
|
+
class << self
|
6
|
+
|
7
|
+
attr_accessor :verbose
|
8
|
+
|
9
|
+
# sleep time between purges
|
10
|
+
attr_writer :sleep_time
|
11
|
+
|
12
|
+
# sets the number of failed jobs to purge per run
|
13
|
+
attr_writer :failed_jobs_limit
|
14
|
+
|
15
|
+
def sleep_time
|
16
|
+
@sleep_time ||= 5.minutes
|
17
|
+
end
|
18
|
+
|
19
|
+
def failed_jobs_limit
|
20
|
+
@failed_jobs_limit ||= 100
|
21
|
+
end
|
22
|
+
|
23
|
+
def stats
|
24
|
+
@stats ||= {}
|
25
|
+
end
|
26
|
+
|
27
|
+
# main loop
|
28
|
+
def run
|
29
|
+
setup
|
30
|
+
|
31
|
+
loop do
|
32
|
+
purge
|
33
|
+
wait
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# run a purge cycle
|
38
|
+
def purge
|
39
|
+
procline("working")
|
40
|
+
add_stat(:loops, 1)
|
41
|
+
|
42
|
+
purge_resque
|
43
|
+
purge_database
|
44
|
+
end
|
45
|
+
|
46
|
+
# display status of failed queues and persistence table
|
47
|
+
def status
|
48
|
+
show_config
|
49
|
+
show_info
|
50
|
+
end
|
51
|
+
|
52
|
+
# allows an app to set a hook to deal with the failed redis job
|
53
|
+
def after_redis_purge(&block)
|
54
|
+
@after_redis_purge = block
|
55
|
+
end
|
56
|
+
|
57
|
+
# allows an app to set a hook to deal with the failed persistence table job
|
58
|
+
def after_database_purge(&block)
|
59
|
+
@after_database_purge = block
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# purger setup and init
|
65
|
+
def setup
|
66
|
+
stats[:pid] = Process.pid
|
67
|
+
stats[:started] = Time.now
|
68
|
+
stats[:loops] = 0
|
69
|
+
|
70
|
+
procline("starting")
|
71
|
+
status
|
72
|
+
register_signal_handlers
|
73
|
+
end
|
74
|
+
|
75
|
+
# intercept signals
|
76
|
+
def register_signal_handlers
|
77
|
+
trap("TERM") { shutdown }
|
78
|
+
trap("INT") { shutdown }
|
79
|
+
trap("QUIT") { shutdown }
|
80
|
+
trap("USR1") { status }
|
81
|
+
end
|
82
|
+
|
83
|
+
def show_config
|
84
|
+
log("Configuration:")
|
85
|
+
[ :sleep_time, :failed_jobs_limit ].each do |v|
|
86
|
+
log("\tconfig #{v} = #{send(v)}")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def show_info
|
91
|
+
with_redis do |redis|
|
92
|
+
set_stat("failed queue length on #{redis.id}", redis.llen(:failed))
|
93
|
+
end
|
94
|
+
|
95
|
+
log!("Status:")
|
96
|
+
stats.each do |key, value|
|
97
|
+
log!("\t#{key} : #{value}")
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# just sleep for a while
|
102
|
+
def wait
|
103
|
+
procline("sleeping for #{sleep_time} seconds")
|
104
|
+
sleep(sleep_time)
|
105
|
+
end
|
106
|
+
|
107
|
+
# shutdown the process
|
108
|
+
def shutdown
|
109
|
+
procline("shutting down")
|
110
|
+
status
|
111
|
+
exit
|
112
|
+
end
|
113
|
+
|
114
|
+
# update process line
|
115
|
+
def procline(string)
|
116
|
+
log(string)
|
117
|
+
$0 = "resque-pertry purger: #{string}"
|
118
|
+
end
|
119
|
+
|
120
|
+
# purge jobs in the failed queue
|
121
|
+
def purge_resque
|
122
|
+
with_redis do |redis|
|
123
|
+
purge_redis(redis)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def with_redis(&block)
|
128
|
+
return {} unless block_given?
|
129
|
+
|
130
|
+
# testing the redis class name so we don't have to require resque_redis_composite
|
131
|
+
case Resque.redis.redis.class.name
|
132
|
+
when "Redis"
|
133
|
+
{ redis.id => block.call(Resque.redis) }
|
134
|
+
when "Resque::RedisComposite"
|
135
|
+
Resque.redis.mapping.reduce({}) do |results, (queue, redis)|
|
136
|
+
results[redis.id] = block.call(redis)
|
137
|
+
results
|
138
|
+
end
|
139
|
+
else
|
140
|
+
raise NotImplementedError, "Unsupported redis client #{Resque.redis.inspect}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# purge resque's failed queue on a redis client
|
145
|
+
def purge_redis(redis)
|
146
|
+
failed_jobs = redis.lrange(:failed, 0, failed_jobs_limit - 1)
|
147
|
+
return 0 if failed_jobs.empty?
|
148
|
+
|
149
|
+
log("purging #{failed_jobs.size} failed jobs from #{redis.id}")
|
150
|
+
redis.ltrim(:failed, failed_jobs.size, -1)
|
151
|
+
|
152
|
+
failed_jobs.each do |failed_job|
|
153
|
+
run_after_redis_purge(failed_job, redis)
|
154
|
+
end if @after_redis_purge
|
155
|
+
|
156
|
+
add_stat("purged from #{redis.id}", failed_jobs.size)
|
157
|
+
end
|
158
|
+
|
159
|
+
# purge resque-pertry persistence table
|
160
|
+
def purge_database
|
161
|
+
jobs = ResquePertryPersistence.finnished_or_expired.limit(failed_jobs_limit)
|
162
|
+
return 0 if jobs.empty?
|
163
|
+
|
164
|
+
log("purging #{jobs.size} completed, failed, or expired jobs from database")
|
165
|
+
|
166
|
+
jobs.each do |job|
|
167
|
+
ResquePertryPersistence.destroy(job.id)
|
168
|
+
run_after_database_purge(job)
|
169
|
+
end if @after_database_purge
|
170
|
+
|
171
|
+
add_stat("purged from database", jobs.size)
|
172
|
+
end
|
173
|
+
|
174
|
+
# run hook after_redis_purge
|
175
|
+
def run_after_redis_purge(job, redis)
|
176
|
+
return unless @after_redis_purge
|
177
|
+
@after_redis_purge.call(job, redis)
|
178
|
+
rescue => e
|
179
|
+
log!("exception #{e.inspect} while running hook after_redis_purge on job #{job.inspect}")
|
180
|
+
end
|
181
|
+
|
182
|
+
# run hook after_database_purge
|
183
|
+
def run_after_database_purge(job)
|
184
|
+
return unless @after_database_purge
|
185
|
+
@after_database_purge.call(job)
|
186
|
+
rescue => e
|
187
|
+
log!("exception #{e.inspect} while running hook after_database_purge on job #{job.inspect}")
|
188
|
+
end
|
189
|
+
|
190
|
+
# only print if verbose is turned on
|
191
|
+
def log(string)
|
192
|
+
log!(string) if verbose
|
193
|
+
end
|
194
|
+
|
195
|
+
# always print this string
|
196
|
+
def log!(string)
|
197
|
+
$stdout.puts "#{Time.now.strftime("%Y-%m-%d %H:%M:%S.%L")} - #{string}"
|
198
|
+
end
|
199
|
+
|
200
|
+
def add_stat(stat, value)
|
201
|
+
stats[stat] ||= 0
|
202
|
+
stats[stat] += value
|
203
|
+
value
|
204
|
+
end
|
205
|
+
|
206
|
+
def set_stat(stat, value)
|
207
|
+
stats[stat] = value
|
208
|
+
end
|
209
|
+
|
210
|
+
end
|
211
|
+
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module Resque
|
2
|
+
module Pertry
|
3
|
+
class ResquePertryPersistence < ActiveRecord::Base
|
4
|
+
|
5
|
+
self.table_name = "resque_pertry_persistence"
|
6
|
+
|
7
|
+
default_scope -> { order(:updated_at) }
|
8
|
+
scope :completed, -> { where("completed_at IS NOT NULL") }
|
9
|
+
scope :failed, -> { where("failed_at IS NOT NULL") }
|
10
|
+
scope :finnished, -> { where("completed_at IS NOT NULL OR failed_at IS NOT NULL") }
|
11
|
+
scope :ongoing, -> { where(:completed_at => nil, :failed_at => nil) }
|
12
|
+
scope :expired, -> { where("expires_at < ?", Time.now) }
|
13
|
+
scope :finnished_or_expired, -> { where("completed_at IS NOT NULL OR failed_at IS NOT NULL OR expires_at < ?", Time.now) }
|
14
|
+
|
15
|
+
class << self
|
16
|
+
|
17
|
+
def create_job_if_needed(klass, args)
|
18
|
+
with_job(klass, args) do |job|
|
19
|
+
# we already have a job for this, we don't want another
|
20
|
+
return if job
|
21
|
+
|
22
|
+
# creating a new job
|
23
|
+
params = {
|
24
|
+
:audit_id => field_from_args('audit_id', args),
|
25
|
+
:job => klass.to_s,
|
26
|
+
:arguments => Resque.encode(args),
|
27
|
+
:attempt => 0,
|
28
|
+
:enqueued_at => field_from_args('queue_time', args)
|
29
|
+
}
|
30
|
+
|
31
|
+
params[:expires_at] = params[:enqueued_at] + klass.retry_ttl if klass.retry_ttl
|
32
|
+
|
33
|
+
new(params).save!
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
def finnish_job(klass, args)
|
39
|
+
with_job(klass, args) do |job|
|
40
|
+
return unless job
|
41
|
+
job.update_attribute(:completed_at, Time.now)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def trying_job(klass, args)
|
46
|
+
with_job(klass, args) do |job|
|
47
|
+
return unless job
|
48
|
+
job.update_attributes( :last_tried_at => Time.now,
|
49
|
+
:attempt => job.attempt + 1)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def fail_job(klass, args)
|
54
|
+
with_job(klass, args) do |job|
|
55
|
+
return unless job
|
56
|
+
job.update_attribute(:failed_at, Time.now)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def with_job(klass, args, &block)
|
61
|
+
audit_id = field_from_args('audit_id', args)
|
62
|
+
return unless audit_id
|
63
|
+
|
64
|
+
job = find_by_audit_id(audit_id)
|
65
|
+
block.call(job) if block_given?
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def field_from_args(field, args)
|
71
|
+
pertry_hash = args[Resque::Plugins::Pertry::JOB_HASH] || args[Resque::Plugins::Pertry::JOB_HASH.to_s] || {}
|
72
|
+
pertry_hash[field]
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
def has_completed?
|
78
|
+
completed_at
|
79
|
+
end
|
80
|
+
|
81
|
+
def payload
|
82
|
+
@payload ||= Resque.decode(arguments)
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
module Resque
|
2
|
+
module Pertry
|
3
|
+
module Retry
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
class_attribute :_retry_delays
|
8
|
+
class_attribute :_retry_delay
|
9
|
+
class_attribute :_retry_attempts
|
10
|
+
class_attribute :_retry_ttl
|
11
|
+
class_attribute :_retry_exceptions
|
12
|
+
|
13
|
+
# set a default max number of retry attempts
|
14
|
+
set_retry_attempts 1
|
15
|
+
|
16
|
+
# set job to expire in 1 week (we need a default so we can purge the database)
|
17
|
+
set_retry_ttl 1.week
|
18
|
+
end
|
19
|
+
|
20
|
+
module ClassMethods
|
21
|
+
|
22
|
+
# Sets a number of seconds to wait before retrying
|
23
|
+
def set_retry_delay(delay)
|
24
|
+
self._retry_delays = nil
|
25
|
+
self._retry_delay = Integer(delay)
|
26
|
+
end
|
27
|
+
|
28
|
+
def retry_delay
|
29
|
+
self._retry_delay
|
30
|
+
end
|
31
|
+
|
32
|
+
# Sets a list of delays (list length will be the # of attempts)
|
33
|
+
def set_retry_delays(*delays)
|
34
|
+
self._retry_attempts = nil
|
35
|
+
self._retry_delay = nil
|
36
|
+
self._retry_delays = Array(delays).map { |delay| Integer(delay) }
|
37
|
+
end
|
38
|
+
|
39
|
+
def retry_delays
|
40
|
+
self._retry_delays
|
41
|
+
end
|
42
|
+
|
43
|
+
# Sets the maximum number of times we will retry
|
44
|
+
def set_retry_attempts(count)
|
45
|
+
self._retry_delays = nil
|
46
|
+
self._retry_attempts = Integer(count)
|
47
|
+
end
|
48
|
+
|
49
|
+
def retry_attempts
|
50
|
+
self._retry_attempts
|
51
|
+
end
|
52
|
+
|
53
|
+
# Sets the maximum time-to-live of the job, after which no attempts will ever be made
|
54
|
+
def set_retry_ttl(ttl)
|
55
|
+
self._retry_ttl = Integer(ttl)
|
56
|
+
end
|
57
|
+
|
58
|
+
def retry_ttl
|
59
|
+
self._retry_ttl
|
60
|
+
end
|
61
|
+
|
62
|
+
# Sets a list of exceptions that we want to retry
|
63
|
+
# If none are set, we will retry every exceptions
|
64
|
+
def set_retry_exceptions(*exceptions)
|
65
|
+
self._retry_exceptions = Array(exceptions)
|
66
|
+
end
|
67
|
+
|
68
|
+
def retry_exceptions
|
69
|
+
self._retry_exceptions
|
70
|
+
end
|
71
|
+
|
72
|
+
# Check if we will retry this job on failure
|
73
|
+
# There has to be a constraint on the number of times we will retry a failing job
|
74
|
+
# or have a ttl, otherwise we could be retrying job endlessly
|
75
|
+
def retryable?
|
76
|
+
retry_attempts || retry_delays || retry_ttl
|
77
|
+
end
|
78
|
+
|
79
|
+
# Resque around_perform hook
|
80
|
+
def around_perform_pertry_00_retry(args = {})
|
81
|
+
ResquePertryPersistence.trying_job(self, args)
|
82
|
+
|
83
|
+
yield
|
84
|
+
end
|
85
|
+
|
86
|
+
# Resque on_failure hook (job failed)
|
87
|
+
def on_failure_pertry_00_retry(exception, args = {})
|
88
|
+
return unless retryable?
|
89
|
+
|
90
|
+
ResquePertryPersistence.with_job(self, args) do |job_model|
|
91
|
+
job = instance(args)
|
92
|
+
|
93
|
+
unless job.retry!(job_model, exception)
|
94
|
+
ResquePertryPersistence.fail_job(self, args)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
# Checks if we can retry
|
102
|
+
def retry?(model, exception)
|
103
|
+
# check the obvious
|
104
|
+
return false unless model
|
105
|
+
return false if model.has_completed?
|
106
|
+
|
107
|
+
# job has used up all it's allowed attempts
|
108
|
+
return false if max_attempt_reached?(model)
|
109
|
+
|
110
|
+
# job exception is not whitelisted for retries
|
111
|
+
return false unless exception_whitelisted?(model, exception)
|
112
|
+
|
113
|
+
# job has expired
|
114
|
+
return false if ttl_expired?(model)
|
115
|
+
|
116
|
+
# seems like we should be able to retry this job
|
117
|
+
return true
|
118
|
+
end
|
119
|
+
|
120
|
+
# Retry the job
|
121
|
+
def retry!(model, exception)
|
122
|
+
return false unless retry?(model, exception)
|
123
|
+
|
124
|
+
delay = delay_before_retry(model)
|
125
|
+
return false unless delay
|
126
|
+
|
127
|
+
Resque.enqueue_in(delay, self.class, payload)
|
128
|
+
end
|
129
|
+
|
130
|
+
def exception_whitelisted?(model, exception)
|
131
|
+
# all exceptions are whitelisted implicitly if we didn't set the exception list
|
132
|
+
return true unless self.class.retry_exceptions
|
133
|
+
|
134
|
+
self.class.retry_exceptions.include?(exception.class)
|
135
|
+
end
|
136
|
+
|
137
|
+
def ttl_expired?(model)
|
138
|
+
# if we didn't set a ttl, it hasn't expired
|
139
|
+
return false unless self.class.retry_ttl
|
140
|
+
|
141
|
+
model.expires_at < Time.now
|
142
|
+
end
|
143
|
+
|
144
|
+
def max_attempt_reached?(model)
|
145
|
+
if self.class.retry_attempts && self.class.retry_attempts < model.attempt
|
146
|
+
true
|
147
|
+
elsif self.class.retry_delays && self.class.retry_delays.size < model.attempt
|
148
|
+
true
|
149
|
+
else
|
150
|
+
false
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def delay_before_retry(model)
|
155
|
+
if self.class.retry_delays
|
156
|
+
self.class.retry_delays[ model.attempt - 1 ]
|
157
|
+
else
|
158
|
+
self.class.retry_delay || 0
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'resque/tasks'
|
2
|
+
require 'resque_scheduler/tasks'
|
3
|
+
|
4
|
+
namespace :resque do
|
5
|
+
task :setup
|
6
|
+
|
7
|
+
namespace :pertry do
|
8
|
+
desc "Start Resque Pertry database and failed queue purger"
|
9
|
+
task :purger => :pertry_setup do
|
10
|
+
File.open(ENV['PIDFILE'], 'w') { |f| f << Process.pid.to_s } if ENV['PIDFILE']
|
11
|
+
|
12
|
+
Resque::Pertry::Purger.sleep_time = Integer(ENV['PURGER_SLEEP']) if ENV['PURGER_SLEEP']
|
13
|
+
Resque::Pertry::Purger.failed_jobs_limit = Integer(ENV['PURGER_LIMIT']) if ENV['PURGER_LIMIT']
|
14
|
+
Resque::Pertry::Purger.verbose = true if ENV['VERBOSE']
|
15
|
+
Resque::Pertry::Purger.run
|
16
|
+
end
|
17
|
+
|
18
|
+
task :pertry_setup do
|
19
|
+
Rake::Task['resque:setup'].invoke
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module Resque
|
2
|
+
module Plugins
|
3
|
+
class Pertry
|
4
|
+
JOB_HASH = :_pertry
|
5
|
+
|
6
|
+
include Resque::Pertry::Persistence
|
7
|
+
include Resque::Pertry::Retry
|
8
|
+
|
9
|
+
class << self
|
10
|
+
|
11
|
+
# Enqueue a job
|
12
|
+
def enqueue(args = {})
|
13
|
+
raise ArgumentError, "Invalid arguments, expecting a Hash but got: #{args.inspect}" unless Hash === args
|
14
|
+
|
15
|
+
args.symbolize_keys!
|
16
|
+
args = check_arguments(args)
|
17
|
+
raise ArgumentError, "Invalid arguments, #{JOB_HASH} is a reserved argument!" if args.key?(JOB_HASH)
|
18
|
+
|
19
|
+
Resque.enqueue(self, args)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Perform a job
|
23
|
+
def perform(args = {})
|
24
|
+
raise ArgumentError, "Invalid arguments, expecting a Hash but got: #{args.inspect}" unless Hash === args
|
25
|
+
|
26
|
+
instance(args).perform
|
27
|
+
end
|
28
|
+
|
29
|
+
def instance(args = {})
|
30
|
+
args.symbolize_keys!
|
31
|
+
raise ArgumentError, "Job is not supported, missing key #{JOB_HASH} from payload #{args.inspect}" unless args.key?(JOB_HASH)
|
32
|
+
|
33
|
+
new(check_arguments(args), args[JOB_HASH])
|
34
|
+
end
|
35
|
+
|
36
|
+
# Specificy job queue
|
37
|
+
def in_queue(queue)
|
38
|
+
@queue = queue.to_sym
|
39
|
+
end
|
40
|
+
|
41
|
+
# Get job queue
|
42
|
+
def queue
|
43
|
+
@queue or raise ArgumentError, "No queue defined for job #{self.name}!"
|
44
|
+
end
|
45
|
+
|
46
|
+
# Define required job attributes
|
47
|
+
def needs(*arguments)
|
48
|
+
arguments.each do |argument|
|
49
|
+
if Hash === argument
|
50
|
+
argument.each do |key, default|
|
51
|
+
self.required_arguments << { :name => key, :default => default }
|
52
|
+
end
|
53
|
+
else
|
54
|
+
self.required_arguments << { :name => argument }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# List of required attributes
|
60
|
+
def required_arguments
|
61
|
+
@required_arguments ||= []
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
# Check that job arguments match required arguments
|
67
|
+
def check_arguments(provided_arguments)
|
68
|
+
required_arguments.inject({}) do |checked_arguments, argument|
|
69
|
+
raise ArgumentError, "#{self} is missing required argument #{argument[:name]} from #{provided_arguments.inspect}" unless provided_arguments.member?(argument[:name]) || argument.member?(:default)
|
70
|
+
|
71
|
+
provided_argument = provided_arguments[argument[:name]] || argument[:default]
|
72
|
+
# TODO check that provided_argument is serializable as json
|
73
|
+
|
74
|
+
checked_arguments[argument[:name]] = provided_argument
|
75
|
+
checked_arguments
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
|
81
|
+
def initialize(arguments, job_properties)
|
82
|
+
set_job_arguments(arguments)
|
83
|
+
set_job_properties(job_properties)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Perform method needs to be overridden in job classes
|
87
|
+
def perform
|
88
|
+
raise NoMethodError, "No method #{self.class.name}#perform defined!"
|
89
|
+
end
|
90
|
+
|
91
|
+
def arguments
|
92
|
+
@_arguments
|
93
|
+
end
|
94
|
+
|
95
|
+
def payload
|
96
|
+
@_arguments.merge(JOB_HASH => @_job_properties)
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def set_job_properties(hash)
|
102
|
+
@_job_properties ||= {}
|
103
|
+
@_job_properties.merge!(hash)
|
104
|
+
end
|
105
|
+
|
106
|
+
def set_job_arguments(hash)
|
107
|
+
@_arguments = hash
|
108
|
+
arguments.each do |key, val|
|
109
|
+
instance_variable_set("@#{key}", val)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "resque-pertry"
|
3
|
+
s.description = "Adds persistence to Resque jobs, and retry properties"
|
4
|
+
s.version = "1.0.0"
|
5
|
+
s.authors = [ "Anthony Powles" ]
|
6
|
+
s.email = "rubygems+resque-pertry@idreamz.net"
|
7
|
+
s.summary = "Allows job to be persistent, and be retried in case of failure"
|
8
|
+
s.homepage = "https://github.com/yogin/resque-pertry"
|
9
|
+
s.license = "MIT"
|
10
|
+
s.files = `git ls-files`.split($/)
|
11
|
+
|
12
|
+
s.add_development_dependency "bundler", "~> 1.3"
|
13
|
+
s.add_development_dependency "rake"
|
14
|
+
s.add_development_dependency "pry"
|
15
|
+
|
16
|
+
s.add_dependency "activesupport", ">= 3.0.0"
|
17
|
+
s.add_dependency "activerecord", ">= 3.0.0"
|
18
|
+
s.add_dependency "resque"
|
19
|
+
s.add_dependency "resque-scheduler", ">= 2.0.0"
|
20
|
+
s.add_dependency "uuidtools", "~> 2.1.4"
|
21
|
+
end
|
metadata
ADDED
@@ -0,0 +1,187 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: resque-pertry
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Anthony Powles
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-08-02 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: bundler
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '1.3'
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1.3'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: rake
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: pry
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: activesupport
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: 3.0.0
|
70
|
+
type: :runtime
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: 3.0.0
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: activerecord
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: 3.0.0
|
86
|
+
type: :runtime
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: 3.0.0
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: resque
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ! '>='
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
type: :runtime
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ! '>='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: resque-scheduler
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: 2.0.0
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: 2.0.0
|
126
|
+
- !ruby/object:Gem::Dependency
|
127
|
+
name: uuidtools
|
128
|
+
requirement: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ~>
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: 2.1.4
|
134
|
+
type: :runtime
|
135
|
+
prerelease: false
|
136
|
+
version_requirements: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ~>
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: 2.1.4
|
142
|
+
description: Adds persistence to Resque jobs, and retry properties
|
143
|
+
email: rubygems+resque-pertry@idreamz.net
|
144
|
+
executables: []
|
145
|
+
extensions: []
|
146
|
+
extra_rdoc_files: []
|
147
|
+
files:
|
148
|
+
- .gitignore
|
149
|
+
- Gemfile
|
150
|
+
- LICENSE
|
151
|
+
- README.md
|
152
|
+
- lib/resque/pertry.rb
|
153
|
+
- lib/resque/pertry/migrations/create_resque_pertry_table.rb
|
154
|
+
- lib/resque/pertry/persistence.rb
|
155
|
+
- lib/resque/pertry/purger.rb
|
156
|
+
- lib/resque/pertry/resque_pertry_persistence.rb
|
157
|
+
- lib/resque/pertry/retry.rb
|
158
|
+
- lib/resque/pertry/tasks.rb
|
159
|
+
- lib/resque/plugins/pertry.rb
|
160
|
+
- resque-pertry.gemspec
|
161
|
+
- tasks/resque_pertry.rake
|
162
|
+
homepage: https://github.com/yogin/resque-pertry
|
163
|
+
licenses:
|
164
|
+
- MIT
|
165
|
+
post_install_message:
|
166
|
+
rdoc_options: []
|
167
|
+
require_paths:
|
168
|
+
- lib
|
169
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
170
|
+
none: false
|
171
|
+
requirements:
|
172
|
+
- - ! '>='
|
173
|
+
- !ruby/object:Gem::Version
|
174
|
+
version: '0'
|
175
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
176
|
+
none: false
|
177
|
+
requirements:
|
178
|
+
- - ! '>='
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
181
|
+
requirements: []
|
182
|
+
rubyforge_project:
|
183
|
+
rubygems_version: 1.8.23
|
184
|
+
signing_key:
|
185
|
+
specification_version: 3
|
186
|
+
summary: Allows job to be persistent, and be retried in case of failure
|
187
|
+
test_files: []
|