sidekiq_status 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,64 @@
1
+ # -*- encoding : utf-8 -*-
2
+ module SidekiqStatus
3
+ module Worker
4
+ def self.included(base)
5
+ base.class_eval do
6
+ include Sidekiq::Worker
7
+
8
+ include(InstanceMethods)
9
+
10
+ base.define_singleton_method(:new) do |*args, &block|
11
+ super(*args, &block).extend(Prepending)
12
+ end
13
+ end
14
+ end
15
+
16
+ module Prepending
17
+ def perform(jid)
18
+ @status_container = SidekiqStatus::Container.load(jid)
19
+
20
+ begin
21
+ catch(:killed) do
22
+ set_status('working')
23
+ super(*@status_container.args)
24
+ set_status('complete')
25
+ end
26
+ rescue Exception => exc
27
+ set_status('failed', exc.class.name + ': ' + exc.message + " \n\n " + exc.backtrace.join("\n "))
28
+ raise exc
29
+ end
30
+ end
31
+ end
32
+
33
+ module InstanceMethods
34
+ def status_container
35
+ kill if @status_container.kill_requested?
36
+ @status_container
37
+ end
38
+ alias_method :sc, :status_container
39
+
40
+ def kill
41
+ # NOTE: status_container below should be accessed by instance var instead of an accessor method
42
+ # because the second option will lead to infinite recursing
43
+ @status_container.kill
44
+ throw(:killed)
45
+ end
46
+
47
+ def set_status(status, message = nil)
48
+ self.sc.update_attributes('status' => status, 'message' => message)
49
+ end
50
+
51
+ def at(at, message = nil)
52
+ self.sc.update_attributes('at' => at, 'message' => message)
53
+ end
54
+
55
+ def total=(total)
56
+ self.sc.update_attributes('total' => total)
57
+ end
58
+
59
+ def payload=(payload)
60
+ self.sc.update_attributes('payload' => payload)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,13 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'sidekiq'
3
+
4
+ require 'securerandom'
5
+ require "sidekiq_status/version"
6
+ require "sidekiq_status/client_middleware"
7
+ require "sidekiq_status/container"
8
+ require "sidekiq_status/worker"
9
+ Sidekiq.client_middleware do |chain|
10
+ chain.add SidekiqStatus::ClientMiddleware
11
+ end
12
+
13
+ require 'sidekiq_status/web' if defined?(Sidekiq::Web)
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/sidekiq_status/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Artem Ignatyev"]
6
+ gem.email = ["cryo28@gmail.com"]
7
+ gem.description = "Job status tracking extension for Sidekiq"
8
+ gem.summary = "A Sidekiq extension to track job execution statuses and return job results back to the client in a convenient manner"
9
+ gem.homepage = "https://github.com/cryo28/sidekiq_status"
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "sidekiq_status"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = SidekiqStatus::VERSION
17
+
18
+ gem.add_runtime_dependency("sidekiq", "~> 2.3.3")
19
+
20
+ gem.add_development_dependency("rspec")
21
+ gem.add_development_dependency("simplecov")
22
+ gem.add_development_dependency("rake")
23
+ gem.add_development_dependency("timecop")
24
+
25
+ gem.add_development_dependency("yard")
26
+ gem.add_development_dependency("maruku")
27
+ end
@@ -0,0 +1,313 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'spec_helper'
3
+
4
+ def test_container(container, hash, jid = nil)
5
+ hash.reject { |k, v| k == :last_updated_at }.find do |k, v|
6
+ container.send(k).should == v
7
+ end
8
+
9
+ container.last_updated_at.should == Time.at(hash['last_updated_at']) if hash['last_updated_at']
10
+ container.jid.should == jid if jid
11
+ end
12
+
13
+
14
+ describe SidekiqStatus::Container do
15
+ let(:jid) { "c2db8b1b460608fb32d76b7a" }
16
+ let(:status_key) { described_class.status_key(jid) }
17
+ let(:sample_json_hash) do
18
+ {
19
+ 'args' => ['arg1', 'arg2'],
20
+ 'worker' => 'SidekiqStatus::Worker',
21
+ 'queue' => '',
22
+
23
+ 'status' => "completed",
24
+ 'at' => 50,
25
+ 'total' => 200,
26
+ 'message' => "Some message",
27
+
28
+ 'payload' => {},
29
+ 'last_updated_at' => 1344855831
30
+ }
31
+ end
32
+
33
+ specify ".status_key" do
34
+ jid = SecureRandom.base64
35
+ described_class.status_key(jid).should == "sidekiq_status:#{jid}"
36
+ end
37
+
38
+ specify ".kill_key" do
39
+ described_class.kill_key.should == described_class::KILL_KEY
40
+ end
41
+
42
+
43
+ context "finders" do
44
+ let!(:containers) do
45
+ described_class::STATUS_NAMES.inject({}) do |accum, status_name|
46
+ container = described_class.create()
47
+ container.update_attributes(:status => status_name)
48
+
49
+ accum[status_name] = container
50
+ accum
51
+ end
52
+ end
53
+
54
+ specify ".size" do
55
+ described_class.size.should == containers.size
56
+ end
57
+
58
+ specify ".status_jids" do
59
+ expected = containers.values.map(&:jid).map{ |jid| [jid, anything()] }
60
+ described_class.status_jids.should =~ expected
61
+ described_class.status_jids(0, 0).size.should == 1
62
+ end
63
+
64
+ specify ".statuses" do
65
+ described_class.statuses.should be_all{|st| st.is_a?(described_class) }
66
+ described_class.statuses.size.should == containers.size
67
+ described_class.statuses(0, 0).size.should == 1
68
+ end
69
+
70
+ describe ".delete" do
71
+ before do
72
+ described_class.status_jids.map(&:first).should =~ containers.values.map(&:jid)
73
+ end
74
+
75
+ specify "deletes jobs in specific status" do
76
+ statuses_to_delete = ['waiting', 'complete']
77
+ described_class.delete(statuses_to_delete)
78
+
79
+ described_class.status_jids.map(&:first).should =~ containers.
80
+ reject{ |status_name, container| statuses_to_delete.include?(status_name) }.
81
+ values.
82
+ map(&:jid)
83
+ end
84
+
85
+ specify "deletes jobs in all statuses" do
86
+ described_class.delete()
87
+
88
+ described_class.status_jids.should be_empty
89
+ end
90
+ end
91
+ end
92
+
93
+ specify ".create" do
94
+ SecureRandom.should_receive(:hex).with(12).and_return(jid)
95
+ args = ['arg1', 'arg2', {arg3: 'val3'}]
96
+
97
+ container = described_class.create('args' => args)
98
+ container.should be_a(described_class)
99
+ container.args.should == args
100
+
101
+ # Check default values are set
102
+ test_container(container, described_class::DEFAULTS.reject{|k, v| k == 'args' }, jid)
103
+
104
+ Sidekiq.redis do |conn|
105
+ conn.exists(status_key).should be_true
106
+ end
107
+ end
108
+
109
+ describe ".load" do
110
+ it "raises StatusNotFound exception if status is missing in Redis" do
111
+ expect { described_class.load(jid) }.to raise_exception(described_class::StatusNotFound, jid)
112
+ end
113
+
114
+ it "loads a container from the redis key" do
115
+ json = MultiJson.dump(sample_json_hash)
116
+ Sidekiq.redis { |conn| conn.set(status_key, json) }
117
+
118
+ container = described_class.load(jid)
119
+ test_container(container, sample_json_hash, jid)
120
+ end
121
+
122
+ it "cleans up unprocessed expired kill requests as well" do
123
+ Sidekiq.redis do |conn|
124
+ conn.zadd(described_class.kill_key, [
125
+ [(Time.now - described_class.ttl - 1).to_i, 'a'],
126
+ [(Time.now - described_class.ttl + 1).to_i, 'b'],
127
+ ]
128
+ )
129
+ end
130
+
131
+ json = MultiJson.dump(sample_json_hash)
132
+ Sidekiq.redis { |conn| conn.set(status_key, json) }
133
+ described_class.load(jid)
134
+
135
+ Sidekiq.redis do |conn|
136
+ conn.zscore(described_class.kill_key, 'a').should be_nil
137
+ conn.zscore(described_class.kill_key, 'b').should_not be_nil
138
+ end
139
+ end
140
+ end
141
+
142
+ specify "#dump" do
143
+ hash = sample_json_hash.reject{ |k, v| k == 'last_updated_at' }
144
+ container = described_class.new(jid, hash)
145
+ dump = container.send(:dump)
146
+ dump.should == hash.merge('last_updated_at' => Time.now.to_i)
147
+ end
148
+
149
+ specify "#save saves container to Redis" do
150
+ hash = sample_json_hash.reject{ |k, v| k == 'last_updated_at' }
151
+ described_class.new(jid, hash).save
152
+
153
+ result = Sidekiq.redis{ |conn| conn.get(status_key) }
154
+ result = MultiJson.load(result)
155
+
156
+ result.should == hash.merge('last_updated_at' => Time.now.to_i)
157
+
158
+ Sidekiq.redis{ |conn| conn.ttl(status_key).should >= 0 }
159
+ end
160
+
161
+ specify "#delete" do
162
+ Sidekiq.redis do |conn|
163
+ conn.set(status_key, "something")
164
+ conn.zadd(described_class.kill_key, 0, jid)
165
+ end
166
+
167
+ container = described_class.new(jid)
168
+ container.delete
169
+
170
+ Sidekiq.redis do |conn|
171
+ conn.exists(status_key).should be_false
172
+ conn.zscore(described_class.kill_key, jid).should be_nil
173
+ end
174
+ end
175
+
176
+ specify "#request_kill, #should_kill?, #killable?" do
177
+ container = described_class.new(jid)
178
+ container.kill_requested?.should be_false
179
+ container.should be_killable
180
+
181
+ Sidekiq.redis do |conn|
182
+ conn.zscore(described_class.kill_key, jid).should be_nil
183
+ end
184
+
185
+
186
+ container.request_kill
187
+
188
+ Sidekiq.redis do |conn|
189
+ conn.zscore(described_class.kill_key, jid).should == Time.now.to_i
190
+ end
191
+ container.should be_kill_requested
192
+ container.should_not be_killable
193
+ end
194
+
195
+ specify "#kill" do
196
+ container = described_class.new(jid)
197
+ container.request_kill
198
+ Sidekiq.redis do |conn|
199
+ conn.zscore(described_class.kill_key, jid).should == Time.now.to_i
200
+ end
201
+ container.status.should_not == 'killed'
202
+
203
+
204
+ container.kill
205
+
206
+ Sidekiq.redis do |conn|
207
+ conn.zscore(described_class.kill_key, jid).should be_nil
208
+ end
209
+
210
+ container.status.should == 'killed'
211
+ described_class.load(jid).status.should == 'killed'
212
+ end
213
+
214
+ specify "#pct_complete" do
215
+ container = described_class.new(jid)
216
+ container.at = 1
217
+ container.total = 100
218
+ container.pct_complete.should == 1
219
+
220
+ container.at = 5
221
+ container.total = 200
222
+ container.pct_complete.should == 3 # 2.5.round(0) => 3
223
+ end
224
+
225
+ context "setters" do
226
+ let(:container) { described_class.new(jid) }
227
+
228
+ describe "#at=" do
229
+ it "sets numeric value" do
230
+ container.total = 100
231
+ container.at = 3
232
+ container.at.should == 3
233
+ container.total.should == 100
234
+ end
235
+
236
+ it "raises ArgumentError otherwise" do
237
+ expect{ container.at = "Wrong" }.to raise_exception(ArgumentError)
238
+ end
239
+
240
+ it "adjusts total if its less than new at" do
241
+ container.total = 200
242
+ container.at = 250
243
+ container.total.should == 250
244
+ end
245
+ end
246
+
247
+ describe "#total=" do
248
+ it "sets numeric value" do
249
+ container.total = 50
250
+ container.total.should == 50
251
+ end
252
+
253
+ it "raises ArgumentError otherwise" do
254
+ expect{ container.total = "Wrong" }.to raise_exception(ArgumentError)
255
+ end
256
+ end
257
+
258
+ describe "#status=" do
259
+ described_class::STATUS_NAMES.each do |status|
260
+ it "sets status #{status.inspect}" do
261
+ container.status = status
262
+ container.status.should == status
263
+ end
264
+ end
265
+
266
+ it "raises ArgumentError otherwise" do
267
+ expect{ container.status = 'Wrong' }.to raise_exception(ArgumentError)
268
+ end
269
+ end
270
+
271
+ specify "#message=" do
272
+ container.message = 'abcd'
273
+ container.message.should == 'abcd'
274
+
275
+ container.message = nil
276
+ container.message.should be_nil
277
+
278
+ message = double('Message', :to_s => 'to_s')
279
+ container.message = message
280
+ container.message.should == 'to_s'
281
+ end
282
+
283
+ specify "#payload=" do
284
+ container.should respond_to(:payload=)
285
+ end
286
+
287
+ specify "update_attributes" do
288
+ container.update_attributes(:at => 1, 'total' => 3, :message => 'msg', 'status' => 'working')
289
+ reloaded_container = described_class.load(container.jid)
290
+
291
+ reloaded_container.at.should == 1
292
+ reloaded_container.total.should == 3
293
+ reloaded_container.message.should == 'msg'
294
+ reloaded_container.status.should == 'working'
295
+
296
+ expect{ container.update_attributes(:at => 'Invalid') }.to raise_exception(ArgumentError)
297
+ end
298
+ end
299
+
300
+ context "predicates" do
301
+ described_class::STATUS_NAMES.each do |status_name1|
302
+ context "status is #{status_name1}" do
303
+ subject{ described_class.create().tap{|c| c.status = status_name1} }
304
+
305
+ its("#{status_name1}?") { should be_true }
306
+
307
+ (described_class::STATUS_NAMES - [status_name1]).each do |status_name2|
308
+ its("#{status_name2}?") { should be_false }
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,33 @@
1
+ # -*- encoding : utf-8 -*-
2
+ require 'bundler'
3
+ Bundler.setup
4
+
5
+ ENV['RACK_ENV'] = ENV['RAILS_ENV'] = 'test'
6
+
7
+ require 'simplecov'
8
+ SimpleCov.start
9
+
10
+ #require 'sidekiq'
11
+
12
+ require 'sidekiq_status'
13
+ require 'sidekiq/util'
14
+
15
+ require 'timecop'
16
+
17
+ Sidekiq.logger.level = Logger::ERROR
18
+
19
+ require 'sidekiq/redis_connection'
20
+ REDIS = Sidekiq::RedisConnection.create(:url => "redis://localhost/15", :namespace => 'test', :size => 1)
21
+
22
+ RSpec.configure do |c|
23
+ c.before do
24
+ Sidekiq.redis = REDIS
25
+ Sidekiq.redis{ |conn| conn.flushdb }
26
+ end
27
+
28
+ c.around do |example|
29
+ Timecop.freeze(Time.utc(2012)) do
30
+ example.call
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,159 @@
1
+ require 'spec_helper'
2
+
3
+ describe Sidekiq::Worker do
4
+ class SomeWorker
5
+ include SidekiqStatus::Worker
6
+
7
+ def perform(*args)
8
+ some_method(*args)
9
+ end
10
+
11
+ def some_method(*args); end
12
+ end
13
+
14
+ let(:args) { ['arg1', 'arg2', {'arg3' => 'val3'}]}
15
+
16
+ describe ".perform_async" do
17
+ it "invokes middleware which creates sidekiq_status container with the same jid" do
18
+ jid = SomeWorker.perform_async(*args)
19
+ jid.should be_a(String)
20
+
21
+ container = SidekiqStatus::Container.load(jid)
22
+ container.args.should == args
23
+ end
24
+ end
25
+
26
+ describe "#perform (Worker context)" do
27
+ let(:worker) { SomeWorker.new }
28
+
29
+ it "receives jid as parameters, loads container and runs original perform with enqueued args" do
30
+ worker.should_receive(:some_method).with(*args)
31
+ jid = SomeWorker.perform_async(*args)
32
+ worker.perform(jid)
33
+ end
34
+
35
+ it "changes status to working" do
36
+ has_been_run = false
37
+ worker.extend(Module.new do
38
+ define_method(:some_method) do |*args|
39
+ status_container.status.should == 'working'
40
+ has_been_run = true
41
+ end
42
+ end)
43
+
44
+ jid = SomeWorker.perform_async(*args)
45
+ worker.perform(jid)
46
+
47
+ has_been_run.should be_true
48
+ worker.status_container.reload.status.should == 'complete'
49
+ end
50
+
51
+ it "intercepts failures and set status to 'failed' then re-raises the exception" do
52
+ exc = RuntimeError.new('Some error')
53
+ worker.stub(:some_method).and_raise(exc)
54
+
55
+ jid = SomeWorker.perform_async(*args)
56
+
57
+ expect{ worker.perform(jid) }.to raise_exception(exc)
58
+
59
+ container = SidekiqStatus::Container.load(jid)
60
+ container.status.should == 'failed'
61
+ end
62
+
63
+ it "sets status to 'complete' if finishes without errors" do
64
+ jid = SomeWorker.perform_async(*args)
65
+ worker.perform(jid)
66
+
67
+ container = SidekiqStatus::Container.load(jid)
68
+ container.status.should == 'complete'
69
+ end
70
+
71
+ it "handles kill requests if kill requested before job execution" do
72
+ jid = SomeWorker.perform_async(*args)
73
+ container = SidekiqStatus::Container.load(jid)
74
+ container.request_kill
75
+
76
+ worker.perform(jid)
77
+
78
+ container.reload
79
+ container.status.should == 'killed'
80
+ end
81
+
82
+ it "handles kill requests if kill requested amid job execution" do
83
+ jid = SomeWorker.perform_async(*args)
84
+ container = SidekiqStatus::Container.load(jid)
85
+ container.status.should == 'waiting'
86
+
87
+ i = 0
88
+ i_mut = Mutex.new
89
+
90
+ worker.extend(Module.new do
91
+ define_method(:some_method) do |*args|
92
+ loop do
93
+ i_mut.synchronize do
94
+ i += 1
95
+ end
96
+
97
+ status_container.at = i
98
+ end
99
+ end
100
+ end)
101
+
102
+ worker_thread = Thread.new{ worker.perform(jid) }
103
+
104
+
105
+ killer_thread = Thread.new do
106
+ sleep(0.01) while i < 100
107
+ container.reload.status.should == 'working'
108
+ container.request_kill
109
+ end
110
+
111
+ worker_thread.join(2)
112
+ killer_thread.join(1)
113
+
114
+ container.reload
115
+ container.status.should == 'killed'
116
+ container.at.should >= 100
117
+ end
118
+
119
+ it "allows to set at, total and customer payload from the worker" do
120
+ jid = SomeWorker.perform_async(*args)
121
+ container = SidekiqStatus::Container.load(jid)
122
+
123
+ ready = false
124
+ lets_stop = false
125
+
126
+ worker.extend(Module.new do
127
+ define_method(:some_method) do |*args|
128
+ self.total=(200)
129
+ self.at(50, "25% done")
130
+ self.payload = 'some payload'
131
+ ready = true
132
+ sleep(0.01) unless lets_stop
133
+ end
134
+ end)
135
+
136
+ worker_thread = Thread.new{ worker.perform(jid) }
137
+ checker_thread = Thread.new do
138
+ sleep(0.01) unless ready
139
+
140
+ container.reload
141
+ container.status.should == 'working'
142
+ container.at.should == 50
143
+ container.total.should == 200
144
+ container.message.should == '25% done'
145
+ container.payload == 'some payload'
146
+
147
+ lets_stop = true
148
+ end
149
+
150
+ worker_thread.join(10)
151
+ checker_thread.join(10)
152
+
153
+ container.reload
154
+ container.status.should == 'complete'
155
+ container.payload.should == 'some payload'
156
+ container.message.should be_nil
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,44 @@
1
+ h1.wi
2
+ | Job #{@status.jid} is #{@status.status} (#{@status.pct_complete}% done)
3
+ p.intro
4
+
5
+ div id="status_#{@status.jid}" rel="#{@status.status}"
6
+ h2 Details
7
+ div
8
+ table class="table table-striped table-bordered"
9
+ thead
10
+ tr
11
+ th Attribute
12
+ th Value
13
+ tbody
14
+ tr
15
+ td jid
16
+ td= @status.jid
17
+ tr
18
+ td status
19
+ td= @status.status
20
+ tr
21
+ td last updated at
22
+ td= @status.last_updated_at
23
+ tr
24
+ td at
25
+ td= @status.at
26
+ tr
27
+ td total
28
+ td= @status.total
29
+ tr
30
+ td message
31
+ td= @status.message
32
+ tr
33
+ td payload
34
+ td
35
+ code class="prettyprint language-javascript"
36
+ = @status.payload.to_json
37
+ tr
38
+ td job args
39
+ td
40
+ code class="prettyprint language-javascript"
41
+ = @status.args.to_json
42
+
43
+ a href=(to(:statuses)) Back
44
+
@@ -0,0 +1,37 @@
1
+ h1.wi Recent job statuses
2
+
3
+ div.delete_jobs
4
+ | Delete jobs in&nbsp;
5
+ a href="#{to(:statuses)}/delete/complete" onclick="return confirm('Are you sure? Delete is irreversible')" = "complete"
6
+ |,&nbsp;
7
+ a href="#{to(:statuses)}/delete/finished" onclick="return confirm('Are you sure? Delete is irreversible')" title="#{SidekiqStatus::Container::FINISHED_STATUS_NAMES.join(', ')}" = "finished"
8
+ |,&nbsp;
9
+ a href="#{to(:statuses)}/delete/all" onclick="return confirm('Are you sure? Delete is irreversible')" = "all"
10
+ |&nbsp;statuses
11
+
12
+ table class="table table-striped table-bordered"
13
+ tr
14
+ th jid
15
+ th Status
16
+ th Last Updated ↆ
17
+ th Progress
18
+ th Message
19
+ - @statuses.each do |container|
20
+ tr
21
+ td
22
+ a href="#{to(:statuses)}/#{container.jid}" = container.jid
23
+ td= container.status
24
+ td= container.last_updated_at
25
+ td
26
+ - if container.killable?
27
+ a.kill href="#{to(:statuses)}/#{container.jid}/kill" onclick="return confirm('Are you sure?');" Kill
28
+ - elsif container.kill_requested?
29
+ |Kill requested
30
+ td= container.message
31
+ - if @statuses.empty?
32
+ tr
33
+ td colspan="5"
34
+
35
+ == slim :_paging, :locals => { :url => "#{root_path}statuses" }
36
+
37
+