sidekiq_status 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.
@@ -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
+