misha-resque-cleaner 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1 @@
1
+ require 'resque_cleaner'
@@ -0,0 +1,307 @@
1
+ require 'time'
2
+ require 'resque'
3
+ require 'resque/server'
4
+
5
+ module Resque
6
+ module Plugins
7
+ # ResqueCleaner class provides useful functionalities to retry or clean
8
+ # failed jobs. Let's clean up your failed list!
9
+ class ResqueCleaner
10
+ # ResqueCleaner fetches all elements from Redis and checks them
11
+ # by linear when filtering them. Since there is a performance concern,
12
+ # ResqueCleaner handles only the latest x(default 1000) jobs.
13
+ #
14
+ # You can change the value through limiter attribute.
15
+ # e.g. cleaner.limiter.maximum = 5000
16
+ attr_reader :limiter
17
+
18
+ # Set false if you don't show any message.
19
+ attr_accessor :print_message
20
+
21
+ # Initializes instance
22
+ def initialize
23
+ @failure = Resque::Failure.backend
24
+ @print_message = true
25
+ @limiter = Limiter.new self
26
+ end
27
+
28
+ # Returns redis instance.
29
+ def redis
30
+ Resque.redis
31
+ end
32
+
33
+ # Returns failure backend. Only supports redis backend.
34
+ def failure
35
+ @failure
36
+ end
37
+
38
+ # Stats by date.
39
+ def stats_by_date(&block)
40
+ jobs, stats = select(&block), {}
41
+ jobs.each do |job|
42
+ date = job["failed_at"][0,10]
43
+ stats[date] ||= 0
44
+ stats[date] += 1
45
+ end
46
+
47
+ print_stats(stats) if print?
48
+ stats
49
+ end
50
+
51
+ # Stats by class.
52
+ def stats_by_class(&block)
53
+ jobs, stats = select(&block), {}
54
+ jobs.each do |job|
55
+ klass = job["payload"] && job["payload"]["class"] ? job["payload"]["class"] : "UNKNOWN"
56
+ stats[klass] ||= 0
57
+ stats[klass] += 1
58
+ end
59
+
60
+ print_stats(stats) if print?
61
+ stats
62
+ end
63
+
64
+ # Stats by exception.
65
+ def stats_by_exception(&block)
66
+ jobs, stats = select(&block), {}
67
+ jobs.each do |job|
68
+ exception = job["exception"]
69
+ stats[exception] ||= 0
70
+ stats[exception] += 1
71
+ end
72
+
73
+ print_stats(stats) if print?
74
+ stats
75
+ end
76
+
77
+ # Print stats
78
+ def print_stats(stats)
79
+ log too_many_message if @limiter.on?
80
+ stats.keys.sort.each do |k|
81
+ log "%15s: %4d" % [k,stats[k]]
82
+ end
83
+ log "%15s: %4d" % ["total", @limiter.count]
84
+ end
85
+
86
+ # Returns every jobs for which block evaluates to true.
87
+ def select(&block)
88
+ jobs = @limiter.jobs
89
+ block_given? ? @limiter.jobs.select(&block) : jobs
90
+ end
91
+ alias :failure_jobs :select
92
+
93
+ def select_by_regex(regex)
94
+ select do |job|
95
+ job.to_s =~ regex
96
+ end
97
+ end
98
+
99
+ # Clears every jobs for which block evaluates to true.
100
+ def clear(&block)
101
+ cleared = 0
102
+ @limiter.lock do
103
+ @limiter.jobs.each_with_index do |job,i|
104
+ if !block_given? || block.call(job)
105
+ index = @limiter.start_index + i - cleared
106
+ # fetches again since you can't ensure that it is always true:
107
+ # a == endode(decode(a))
108
+ value = redis.lindex(:failed, index)
109
+ redis.lrem(:failed, 1, value)
110
+ cleared += 1
111
+ end
112
+ end
113
+ end
114
+ cleared
115
+ end
116
+
117
+ # Retries every jobs for which block evaluates to true.
118
+ def requeue(clear_after_requeue=false, options={}, &block)
119
+ requeued = 0
120
+ queue = options["queue"] || options[:queue]
121
+ @limiter.lock do
122
+ @limiter.jobs.each_with_index do |job,i|
123
+ if !block_given? || block.call(job)
124
+ index = @limiter.start_index + i - requeued
125
+
126
+ value = redis.lindex(:failed, index)
127
+ redis.multi do
128
+ Job.create(queue||job['queue'], job['payload']['class'], *job['payload']['args'])
129
+
130
+ if clear_after_requeue
131
+ # remove job
132
+ # TODO: should use ltrim. not sure why i used lrem here...
133
+ redis.lrem(:failed, 1, value)
134
+ else
135
+ # mark retried
136
+ job['retried_at'] = Time.now.strftime("%Y/%m/%d %H:%M:%S")
137
+ redis.lset(:failed, @limiter.start_index+i, Resque.encode(job))
138
+ end
139
+ end
140
+
141
+ requeued += 1
142
+ end
143
+ end
144
+ end
145
+ requeued
146
+ end
147
+
148
+ # Clears all jobs except the last X jobs
149
+ def clear_stale
150
+ return 0 unless @limiter.on?
151
+ c = @limiter.maximum
152
+ redis.ltrim(:failed, -c, -1)
153
+ c
154
+ end
155
+
156
+ # Exntends job(Hash instance) with some helper methods.
157
+ module FailedJobEx
158
+ # Returns true if the job has been already retried. Otherwise returns
159
+ # false.
160
+ def retried?
161
+ !self['retried_at'].nil?
162
+ end
163
+ alias :requeued? :retried?
164
+
165
+ # Returns true if the job processed(failed) before the given time.
166
+ # Otherwise returns false.
167
+ # You can pass Time object or String.
168
+ def before?(time)
169
+ time = Time.parse(time) if time.is_a?(String)
170
+ Time.parse(self['failed_at']) < time
171
+ end
172
+
173
+ # Returns true if the job processed(failed) after the given time.
174
+ # Otherwise returns false.
175
+ # You can pass Time object or String.
176
+ def after?(time)
177
+ time = Time.parse(time) if time.is_a?(String)
178
+ Time.parse(self['failed_at']) >= time
179
+ end
180
+
181
+ # Returns true if the class of the job matches. Otherwise returns false.
182
+ def klass?(klass_or_name)
183
+ if self["payload"] && self["payload"]["class"]
184
+ self["payload"]["class"] == klass_or_name.to_s
185
+ else
186
+ klass_or_name=="UNKNOWN"
187
+ end
188
+ end
189
+
190
+ # Returns true if the exception raised by the failed job matches. Otherwise returns false.
191
+ def exception?(exception)
192
+ self["exception"] == exception.to_s
193
+ end
194
+
195
+ # Returns true if the queue of the job matches. Otherwise returns false.
196
+ def queue?(queue)
197
+ self["queue"] == queue.to_s
198
+ end
199
+ end
200
+
201
+ # Through the Limiter class, you accesses only the last x(default 1000)
202
+ # jobs.
203
+ class Limiter
204
+ @@default_maximum ||= 1000
205
+
206
+ class << self
207
+ def default_maximum
208
+ @@default_maximum
209
+ end
210
+
211
+ def default_maximum=(v)
212
+ @@default_maximum = v
213
+ end
214
+ end
215
+
216
+ attr_accessor :maximum
217
+ def initialize(cleaner)
218
+ @cleaner = cleaner
219
+ @maximum = @@default_maximum
220
+ @locked = false
221
+ end
222
+
223
+ # Returns true if limiter is ON: number of failed jobs is more than
224
+ # maximum value.
225
+ def on?
226
+ @cleaner.failure.count > @maximum
227
+ end
228
+
229
+ # Returns limited count.
230
+ def count
231
+ if @locked
232
+ @jobs.size
233
+ else
234
+ on? ? @maximum : @cleaner.failure.count
235
+ end
236
+ end
237
+
238
+ # Returns jobs. If numbers of jobs is more than maximum, it returns only
239
+ # the maximum.
240
+ def jobs
241
+ if @locked
242
+ @jobs
243
+ else
244
+ all( - count, count)
245
+ end
246
+ end
247
+
248
+ # Wraps Resque's all and returns always array.
249
+ def all(index=0,count=1)
250
+ jobs = @cleaner.failure.all( index, count)
251
+ jobs = [] unless jobs
252
+ jobs = [jobs] unless jobs.is_a?(Array)
253
+ jobs.each{|j| j.extend FailedJobEx}
254
+ jobs
255
+ end
256
+
257
+ # Returns a start index of jobs in :failed list.
258
+ def start_index
259
+ if @locked
260
+ @start_index
261
+ else
262
+ on? ? @cleaner.failure.count-@maximum : 0
263
+ end
264
+ end
265
+
266
+ # Assuming new failures pushed while cleaner is dealing with failures,
267
+ # you need to lock the range.
268
+ def lock
269
+ old = @locked
270
+
271
+ unless @locked
272
+ total_count = @cleaner.failure.count
273
+ if total_count>@maximum
274
+ @start_index = total_count-@maximum
275
+ @jobs = all( @start_index, @maximum)
276
+ else
277
+ @start_index = 0
278
+ @jobs = all( 0, total_count)
279
+ end
280
+ end
281
+
282
+ @locked = true
283
+ yield
284
+ ensure
285
+ @locked = old
286
+ end
287
+ end
288
+
289
+ # Outputs message. Overrides this method when you want to change a output
290
+ # stream.
291
+ def log(msg)
292
+ puts msg if print?
293
+ end
294
+
295
+ def print?
296
+ @print_message
297
+ end
298
+
299
+ def too_many_message
300
+ "There are too many failed jobs(count=#{@failure.count}). This only looks at last #{@limiter.maximum} jobs."
301
+ end
302
+ end
303
+ end
304
+ end
305
+
306
+ require 'resque_cleaner/server'
307
+
@@ -0,0 +1,266 @@
1
+ require 'yaml'
2
+
3
+ # Extends Resque Web Based UI.
4
+ # Structure has been borrowed from ResqueScheduler.
5
+ module ResqueCleaner
6
+ module Server
7
+
8
+ def self.erb_path(filename)
9
+ File.join(File.dirname(__FILE__), 'server', 'views', filename)
10
+ end
11
+ def self.public_path(filename)
12
+ File.join(File.dirname(__FILE__), 'server', 'public', filename)
13
+ end
14
+
15
+ # Pagination helper for list page.
16
+ class Paginate
17
+ attr_accessor :page_size, :page, :jobs, :url
18
+ def initialize(jobs, url, page=1, page_size=20)
19
+ @jobs = jobs
20
+ @url = url
21
+ @page = (!page || page < 1) ? 1 : page
22
+ @page_size = 20
23
+ end
24
+
25
+ def first_index
26
+ @page_size * (@page-1)
27
+ end
28
+
29
+ def last_index
30
+ last = first_index + @page_size - 1
31
+ last > @jobs.size-1 ? @jobs.size-1 : last
32
+ end
33
+
34
+ def paginated_jobs
35
+ @jobs[first_index,@page_size]
36
+ end
37
+
38
+ def first_page?
39
+ @page <= 1
40
+ end
41
+
42
+ def last_page?
43
+ @page >= max_page
44
+ end
45
+
46
+ def page_url(page)
47
+ u = @url
48
+ u += @url.include?("?") ? "&" : "?"
49
+ if page.is_a?(Symbol)
50
+ page = @page - 1 if page==:prev
51
+ page = @page + 1 if page==:next
52
+ end
53
+ u += "p=#{page}"
54
+ end
55
+
56
+ def total_size
57
+ @jobs.size
58
+ end
59
+
60
+ def max_page
61
+ ((total_size-1) / @page_size) + 1
62
+ end
63
+ end
64
+
65
+ def self.included(base)
66
+
67
+ base.class_eval do
68
+ helpers do
69
+ def time_filter(id, name, value)
70
+ html = "<select id=\"#{id}\" name=\"#{name}\">"
71
+ html += "<option value=\"\">-</option>"
72
+ [1, 3, 6, 12, 24].each do |h|
73
+ selected = h.to_s == value ? 'selected="selected"' : ''
74
+ html += "<option #{selected} value=\"#{h}\">#{h} #{h==1 ? "hour" : "hours"} ago</option>"
75
+ end
76
+ [3, 7, 14, 28].each do |d|
77
+ selected = (d*24).to_s == value ? 'selected="selected"' : ''
78
+ html += "<option #{selected} value=\"#{d*24}\">#{d} days ago</option>"
79
+ end
80
+ html += "</select>"
81
+ end
82
+
83
+ def class_filter(id, name, klasses, value)
84
+ html = "<select id=\"#{id}\" name=\"#{name}\">"
85
+ html += "<option value=\"\">-</option>"
86
+ klasses.each do |k|
87
+ selected = k == value ? 'selected="selected"' : ''
88
+ html += "<option #{selected} value=\"#{k}\">#{k}</option>"
89
+ end
90
+ html += "</select>"
91
+ end
92
+
93
+ def exception_filter(id, name, exceptions, value)
94
+ html = "<select id=\"#{id}\" name=\"#{name}\">"
95
+ html += "<option value=\"\">-</option>"
96
+ exceptions.each do |ex|
97
+ selected = ex == value ? 'selected="selected"' : ''
98
+ html += "<option #{selected} value=\"#{ex}\">#{ex}</option>"
99
+ end
100
+ html += "</select>"
101
+ end
102
+
103
+ def show_job_args(args)
104
+ Array(args).map { |a| a.inspect }.join("\n")
105
+ end
106
+
107
+ def text_filter(id, name, value)
108
+ html = "<input id=\"#{id}\" type=\"text\" name=\"#{name}\" value=\"#{value}\">"
109
+ html += "</input>"
110
+ end
111
+ end
112
+
113
+ mime_type :json, 'application/json'
114
+
115
+ get "/cleaner" do
116
+ load_library
117
+ load_cleaner_filter
118
+
119
+ @jobs = cleaner.select
120
+ @stats = { :klass => {}, :exception => {} }
121
+ @total = Hash.new(0)
122
+ @jobs.each do |job|
123
+ klass = job["payload"]["class"] || 'UNKNOWN'
124
+ exception = job["exception"] || 'UNKNOWN'
125
+ failed_at = Time.parse job["failed_at"]
126
+ @stats[:klass][klass] ||= Hash.new(0)
127
+ @stats[:exception][exception] ||= Hash.new(0)
128
+
129
+ [
130
+ @stats[:klass][klass],
131
+ @stats[:exception][exception],
132
+ @total
133
+ ].each do |stat|
134
+ stat[:total] += 1
135
+ stat[:h1] += 1 if failed_at >= hours_ago(1)
136
+ stat[:h3] += 1 if failed_at >= hours_ago(3)
137
+ stat[:d1] += 1 if failed_at >= hours_ago(24)
138
+ stat[:d3] += 1 if failed_at >= hours_ago(24*3)
139
+ stat[:d7] += 1 if failed_at >= hours_ago(24*7)
140
+ end
141
+ end
142
+
143
+ erb File.read(ResqueCleaner::Server.erb_path('cleaner.erb'))
144
+ end
145
+
146
+ get "/cleaner_list" do
147
+ load_library
148
+ load_cleaner_filter
149
+ build_urls
150
+
151
+ block = filter_block
152
+
153
+ @failed = cleaner.select(&block).reverse
154
+
155
+ @paginate = Paginate.new(@failed, @list_url, params[:p].to_i)
156
+
157
+ @klasses = cleaner.stats_by_class.keys
158
+ @exceptions = cleaner.stats_by_exception.keys
159
+ @count = cleaner.select(&block).size
160
+
161
+ erb File.read(ResqueCleaner::Server.erb_path('cleaner_list.erb'))
162
+ end
163
+
164
+ post "/cleaner_exec" do
165
+ load_library
166
+ load_cleaner_filter
167
+ build_urls
168
+
169
+ if params[:select_all_pages]!="1"
170
+ @sha1 = {}
171
+ params[:sha1].split(",").each {|s| @sha1[s] = true }
172
+ end
173
+
174
+ block = filter_block
175
+
176
+ @count =
177
+ case params[:action]
178
+ when "clear" then cleaner.clear(&block)
179
+ when "retry_and_clear" then cleaner.requeue(true,&block)
180
+ when "retry" then cleaner.requeue(false,{},&block)
181
+ end
182
+
183
+ erb File.read(ResqueCleaner::Server.erb_path('cleaner_exec.erb'))
184
+ end
185
+
186
+ get "/cleaner_dump" do
187
+ load_library
188
+ load_cleaner_filter
189
+
190
+ block = filter_block
191
+
192
+ content_type :json
193
+ JSON.pretty_generate(cleaner.select(&block))
194
+ end
195
+
196
+ post "/cleaner_stale" do
197
+ load_library
198
+ cleaner.clear_stale
199
+ redirect url_path(:cleaner)
200
+ end
201
+
202
+ get /cleaner\/public\/([a-z]+\.[a-z]+)/ do
203
+ send_file ResqueCleaner::Server.public_path(params[:captures].first)
204
+ end
205
+ end
206
+
207
+ end
208
+
209
+ def cleaner
210
+ @cleaner ||= Resque::Plugins::ResqueCleaner.new
211
+ @cleaner.print_message = false
212
+ @cleaner
213
+ end
214
+
215
+ def load_library
216
+ require 'digest/sha1'
217
+ begin
218
+ require 'yajl/json_gem' unless [].respond_to?(:to_json)
219
+ rescue Exception
220
+ require 'json'
221
+ end
222
+ end
223
+
224
+ def load_cleaner_filter
225
+ @from = params[:f]=="" ? nil : params[:f]
226
+ @to = params[:t]=="" ? nil : params[:t]
227
+ @klass = params[:c]=="" ? nil : params[:c]
228
+ @exception = params[:ex]=="" ? nil : params[:ex]
229
+ @regex = params[:regex]=="" ? nil : params[:regex]
230
+ end
231
+
232
+ def build_urls
233
+ params = {
234
+ c: @klass,
235
+ ex: @exception,
236
+ f: @from,
237
+ t: @to,
238
+ regex: @regex
239
+ }.map {|key,value| "#{key}=#{URI.encode(value.to_s)}"}.join("&")
240
+
241
+ @list_url = "cleaner_list?#{params}"
242
+ @dump_url = "cleaner_dump?#{params}"
243
+ end
244
+
245
+ def filter_block
246
+ block = lambda{|j|
247
+ (!@from || j.after?(hours_ago(@from))) &&
248
+ (!@to || j.before?(hours_ago(@to))) &&
249
+ (!@klass || j.klass?(@klass)) &&
250
+ (!@exception || j.exception?(@exception)) &&
251
+ (!@sha1 || @sha1[Digest::SHA1.hexdigest(j.to_json)]) &&
252
+ (!@regex || j.to_s =~ /#{@regex}/)
253
+ }
254
+ end
255
+
256
+ def hours_ago(h)
257
+ Time.now - h.to_i*60*60
258
+ end
259
+ Resque::Server.tabs << 'Cleaner'
260
+ end
261
+ end
262
+
263
+ Resque::Server.class_eval do
264
+ include ResqueCleaner::Server
265
+ end
266
+