sidekiq-status 3.0.3 → 4.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +2 -0
  3. data/.devcontainer/README.md +57 -0
  4. data/.devcontainer/devcontainer.json +55 -0
  5. data/.devcontainer/docker-compose.yml +19 -0
  6. data/.github/workflows/ci.yaml +9 -6
  7. data/Appraisals +14 -6
  8. data/CHANGELOG.md +12 -0
  9. data/Dockerfile +5 -0
  10. data/README.md +756 -41
  11. data/Rakefile +153 -0
  12. data/docker-compose.yml +15 -0
  13. data/gemfiles/{sidekiq_6.1.gemfile → sidekiq_7.0.gemfile} +1 -1
  14. data/gemfiles/sidekiq_7.3.gemfile +7 -0
  15. data/gemfiles/sidekiq_8.0.gemfile +7 -0
  16. data/gemfiles/{sidekiq_6.x.gemfile → sidekiq_8.x.gemfile} +1 -1
  17. data/lib/sidekiq-status/client_middleware.rb +4 -3
  18. data/lib/sidekiq-status/helpers.rb +94 -0
  19. data/lib/sidekiq-status/server_middleware.rb +6 -21
  20. data/lib/sidekiq-status/storage.rb +12 -3
  21. data/lib/sidekiq-status/version.rb +1 -1
  22. data/lib/sidekiq-status/web.rb +67 -93
  23. data/lib/sidekiq-status/worker.rb +6 -10
  24. data/lib/sidekiq-status.rb +21 -5
  25. data/sidekiq-status.gemspec +7 -1
  26. data/spec/environment.rb +12 -1
  27. data/spec/lib/sidekiq-status/client_middleware_spec.rb +8 -0
  28. data/spec/lib/sidekiq-status/server_middleware_spec.rb +13 -0
  29. data/spec/lib/sidekiq-status/web_spec.rb +72 -3
  30. data/spec/lib/sidekiq-status/worker_spec.rb +3 -3
  31. data/spec/lib/sidekiq-status_spec.rb +20 -3
  32. data/spec/spec_helper.rb +3 -8
  33. data/spec/support/test_jobs.rb +11 -0
  34. data/spec/test_environment.rb +1 -0
  35. data/web/assets/statuses.css +124 -0
  36. data/web/assets/statuses.js +24 -0
  37. data/web/views/status.erb +131 -93
  38. data/web/views/status_not_found.erb +1 -1
  39. data/web/views/statuses.erb +23 -79
  40. metadata +93 -14
@@ -15,13 +15,14 @@ module Sidekiq::Status::Worker
15
15
  end
16
16
 
17
17
  # Read value from job status hash
18
- # @param String|Symbol hask key
18
+ # @param String|Symbol hash key
19
19
  # @return [String]
20
20
  def retrieve(name)
21
21
  read_field_for_id @provider_job_id || @job_id || @jid || "", name
22
22
  end
23
23
 
24
- # Sets current task progress
24
+ # Sets current task progress. This will stop the job if `.stop!` has been
25
+ # called with this job's ID.
25
26
  # (inspired by resque-status)
26
27
  # @param Fixnum number of tasks done
27
28
  # @param String optional message
@@ -29,7 +30,8 @@ module Sidekiq::Status::Worker
29
30
  def at(num, message = nil)
30
31
  @_status_total = 100 if @_status_total.nil?
31
32
  pct_complete = ((num / @_status_total.to_f) * 100).to_i rescue 0
32
- store(at: num, total: @_status_total, pct_complete: pct_complete, message: message, working_at: working_at)
33
+ store(at: num, total: @_status_total, pct_complete: pct_complete, message: message)
34
+ raise Stopped if retrieve(:stop) == 'true'
33
35
  end
34
36
 
35
37
  # Sets total number of tasks
@@ -37,12 +39,6 @@ module Sidekiq::Status::Worker
37
39
  # @return [String]
38
40
  def total(num)
39
41
  @_status_total = num
40
- store(total: num, working_at: working_at)
41
- end
42
-
43
- private
44
-
45
- def working_at
46
- @working_at ||= Time.now.to_i
42
+ store(total: num)
47
43
  end
48
44
  end
@@ -42,6 +42,10 @@ module Sidekiq::Status
42
42
  delete_status(job_id)
43
43
  end
44
44
 
45
+ def stop!(job_id)
46
+ store_for_id(job_id, {stop: 'true'})
47
+ end
48
+
45
49
  alias_method :unschedule, :cancel
46
50
 
47
51
  STATUS.each do |name|
@@ -63,19 +67,31 @@ module Sidekiq::Status
63
67
  get(job_id, :pct_complete).to_i
64
68
  end
65
69
 
66
- def working_at(job_id)
67
- (get(job_id, :working_at) || Time.now).to_i
70
+ def enqueued_at(job_id)
71
+ get(job_id, :enqueued_at)&.to_i
72
+ end
73
+
74
+ def started_at(job_id)
75
+ get(job_id, :started_at)&.to_i
76
+ end
77
+
78
+ def updated_at(job_id)
79
+ # sidekiq-status v3.x and earlier used :update_time
80
+ get(job_id, :updated_at)&.to_i || get(job_id, :update_time)&.to_i
68
81
  end
69
82
 
70
- def update_time(job_id)
71
- (get(job_id, :update_time) || Time.now).to_i
83
+ def ended_at(job_id)
84
+ get(job_id, :ended_at)&.to_i
72
85
  end
73
86
 
74
87
  def eta(job_id)
75
88
  at = at(job_id)
76
89
  return nil if at.zero?
77
90
 
78
- (Time.now.to_i - working_at(job_id)).to_f / at * (total(job_id) - at)
91
+ start_time = started_at(job_id) || enqueued_at(job_id) || updated_at(job_id)
92
+ elapsed = Time.now.to_i - start_time if start_time
93
+ return nil unless elapsed
94
+ elapsed.to_f / at * (total(job_id) - at)
79
95
  end
80
96
 
81
97
  def message(job_id)
@@ -12,14 +12,20 @@ Gem::Specification.new do |gem|
12
12
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
13
13
  gem.name = 'sidekiq-status'
14
14
  gem.require_paths = ['lib']
15
+ gem.required_ruby_version = '>= 3.2'
15
16
  gem.version = Sidekiq::Status::VERSION
16
17
 
17
- gem.add_dependency 'sidekiq', '>= 6.0', '< 8'
18
+ gem.add_dependency 'sidekiq', '>= 7', '< 9'
18
19
  gem.add_dependency 'chronic_duration'
20
+ gem.add_dependency 'logger'
21
+ gem.add_dependency 'base64'
19
22
  gem.add_development_dependency 'appraisal'
20
23
  gem.add_development_dependency 'colorize'
24
+ gem.add_development_dependency 'irb'
21
25
  gem.add_development_dependency 'rack-test'
22
26
  gem.add_development_dependency 'rake'
23
27
  gem.add_development_dependency 'rspec'
24
28
  gem.add_development_dependency 'sinatra'
29
+ gem.add_development_dependency 'webrick'
30
+ gem.add_development_dependency 'rack-session'
25
31
  end
data/spec/environment.rb CHANGED
@@ -1 +1,12 @@
1
- # This file has been intentionally left blank
1
+ # This file is used to load the test environment for Sidekiq::Status when launching Sidekiq workers directly
2
+ require "sidekiq-status"
3
+ require_relative "support/test_jobs"
4
+
5
+ Sidekiq.configure_client do |config|
6
+ Sidekiq::Status.configure_client_middleware config
7
+ end
8
+
9
+ Sidekiq.configure_server do |config|
10
+ Sidekiq::Status.configure_server_middleware config
11
+ Sidekiq::Status.configure_client_middleware config
12
+ end
@@ -38,6 +38,14 @@ describe Sidekiq::Status::ClientMiddleware do
38
38
  end
39
39
  end
40
40
 
41
+ context "when first argument is a string containing substring 'job_class'" do
42
+ it "uses the constantized class name" do
43
+ expect(StubJob.perform_async 'a string with job_class inside').to eq(job_id)
44
+ expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('queued')
45
+ expect(Sidekiq::Status::queued?(job_id)).to be_truthy
46
+ expect(Sidekiq::Status::get_all(job_id)).to include('worker' => 'StubJob')
47
+ end
48
+ end
41
49
  end
42
50
 
43
51
  describe "with :expiration parameter" do
@@ -45,6 +45,19 @@ describe Sidekiq::Status::ServerMiddleware do
45
45
  expect(Sidekiq::Status::failed?(job_id)).to be_truthy
46
46
  end
47
47
 
48
+ context "when first argument is a string containing substring 'job_class'" do
49
+ it "uses the default class name" do
50
+ allow(SecureRandom).to receive(:hex).once.and_return(job_id)
51
+ start_server do
52
+ expect(capture_status_updates(3) {
53
+ expect(ConfirmationJob.perform_async 'a string with job_class inside').to eq(job_id)
54
+ }).to eq([job_id]*3)
55
+ end
56
+ expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('complete')
57
+ expect(Sidekiq::Status::get_all(job_id)).to include('worker' => 'ConfirmationJob')
58
+ end
59
+ end
60
+
48
61
  context "when Sidekiq::Status::Worker is not included in the job" do
49
62
  it "should not set a failed status" do
50
63
  allow(SecureRandom).to receive(:hex).once.and_return(job_id)
@@ -1,6 +1,7 @@
1
1
  require 'spec_helper'
2
2
  require 'sidekiq-status/web'
3
3
  require 'rack/test'
4
+ require 'base64'
4
5
 
5
6
  describe 'sidekiq status web' do
6
7
  include Rack::Test::Methods
@@ -9,13 +10,14 @@ describe 'sidekiq status web' do
9
10
  let!(:job_id) { SecureRandom.hex(12) }
10
11
 
11
12
  def app
12
- Sidekiq::Web
13
+ @app ||= Sidekiq::Web.new
13
14
  end
14
15
 
15
16
  before do
16
- env 'rack.session', csrf: Base64.urlsafe_encode64('token')
17
- client_middleware
18
17
  allow(SecureRandom).to receive(:hex).and_return(job_id)
18
+ # Set up a basic session for Sidekiq's CSRF protection
19
+ env 'rack.session', {}
20
+ client_middleware
19
21
  end
20
22
 
21
23
  around { |example| start_server(&example) }
@@ -81,4 +83,71 @@ describe 'sidekiq status web' do
81
83
  expect(last_response).to be_not_found
82
84
  expect(last_response.body).to match(/That job can't be found/)
83
85
  end
86
+
87
+ it 'handles POST with PUT method override for retrying failed jobs' do
88
+ # Create a failed job first
89
+ capture_status_updates(3) do
90
+ FailingJob.perform_async
91
+ end
92
+
93
+ # First make a GET request to establish the session and get the CSRF token
94
+ get '/statuses'
95
+ expect(last_response).to be_ok
96
+
97
+ # Extract the CSRF token from the environment
98
+ csrf_token = last_request.env[:csrf_token]
99
+
100
+ # Simulate the retry form submission with a referer header
101
+ header 'Referer', 'http://example.com/statuses'
102
+ post '/statuses', {
103
+ 'jid' => job_id,
104
+ '_method' => 'put',
105
+ 'authenticity_token' => csrf_token
106
+ }
107
+
108
+ expect(last_response.status).to eq(302)
109
+ expect(last_response.headers['Location']).to eq('http://example.com/statuses')
110
+ end
111
+
112
+ it 'handles POST with DELETE method override for removing completed jobs' do
113
+ # Create a completed job first
114
+ capture_status_updates(2) do
115
+ StubJob.perform_async
116
+ end
117
+
118
+ # First make a GET request to establish the session and get the CSRF token
119
+ get '/statuses'
120
+ expect(last_response).to be_ok
121
+
122
+ # Extract the CSRF token from the environment
123
+ csrf_token = last_request.env[:csrf_token]
124
+
125
+ # Simulate the remove form submission with a referer header
126
+ header 'Referer', 'http://example.com/statuses'
127
+ post '/statuses', {
128
+ 'jid' => job_id,
129
+ '_method' => 'delete',
130
+ 'authenticity_token' => csrf_token
131
+ }
132
+
133
+ expect(last_response.status).to eq(302)
134
+ expect(last_response.headers['Location']).to eq('http://example.com/statuses')
135
+ expect(Sidekiq::Status.status(job_id)).to be_nil
136
+ end
137
+
138
+ it 'returns 405 for POST without valid method override' do
139
+ # First make a GET request to establish the session and get the CSRF token
140
+ get '/statuses'
141
+ expect(last_response).to be_ok
142
+
143
+ # Extract the CSRF token from the environment
144
+ csrf_token = last_request.env[:csrf_token]
145
+
146
+ post '/statuses', {
147
+ 'jid' => job_id,
148
+ 'authenticity_token' => csrf_token
149
+ }
150
+
151
+ expect(last_response.status).to eq(405)
152
+ end
84
153
  end
@@ -25,17 +25,17 @@ describe Sidekiq::Status::Worker do
25
25
  subject { StubJob.new }
26
26
 
27
27
  it "records when the worker has started" do
28
- expect { subject.at(0) }.to(change { subject.retrieve('working_at') })
28
+ expect { subject.at(0) }.to(change { subject.retrieve('updated_at') })
29
29
  end
30
30
 
31
31
  context "when setting the total for the worker" do
32
32
  it "records when the worker has started" do
33
- expect { subject.total(100) }.to(change { subject.retrieve('working_at') })
33
+ expect { subject.total(100) }.to(change { subject.retrieve('updated_at') })
34
34
  end
35
35
  end
36
36
 
37
37
  it "records when the worker last worked" do
38
- expect { subject.at(0) }.to(change { subject.retrieve('update_time') })
38
+ expect { subject.at(0) }.to(change { subject.retrieve('updated_at') })
39
39
  end
40
40
  end
41
41
  end
@@ -72,10 +72,13 @@ describe Sidekiq::Status do
72
72
  expect(LongJob.perform_async(0.5)).to eq(job_id)
73
73
  }).to eq([job_id]*2)
74
74
  expect(hash = Sidekiq::Status.get_all(job_id)).to include 'status' => 'working'
75
- expect(hash).to include 'update_time'
75
+ expect(hash).to include 'started_at'
76
+ expect(hash).to include 'updated_at'
76
77
  end
77
78
  expect(hash = Sidekiq::Status.get_all(job_id)).to include 'status' => 'complete'
78
- expect(hash).to include 'update_time'
79
+ expect(hash).to include 'started_at'
80
+ expect(hash).to include 'updated_at'
81
+ expect(hash).to include 'ended_at'
79
82
  end
80
83
  end
81
84
 
@@ -138,6 +141,20 @@ describe Sidekiq::Status do
138
141
  end
139
142
  end
140
143
 
144
+ describe ".stop!" do
145
+ it "allows a job to be stopped" do
146
+ allow(SecureRandom).to receive(:hex).once.and_return(job_id)
147
+ start_server do
148
+ expect(capture_status_updates(1) {
149
+ expect(LongProgressJob.perform_async).to eq(job_id)
150
+ expect(Sidekiq::Status.stop!(job_id)).to be_truthy
151
+ }).to eq([job_id]*1)
152
+ end
153
+ expect(Sidekiq::Status.at(job_id)).to be(0)
154
+ expect(Sidekiq::Status.stopped?(job_id)).to be_truthy
155
+ end
156
+ end
157
+
141
158
  context "keeps normal Sidekiq functionality" do
142
159
  let(:expiration_param) { nil }
143
160
 
@@ -152,7 +169,7 @@ describe Sidekiq::Status do
152
169
  seed_secure_random_with_job_ids
153
170
  start_server(:expiration => expiration_param) do
154
171
  expect {
155
- Sidekiq::Client.new(Sidekiq.redis_pool).
172
+ Sidekiq::Client.new(pool: Sidekiq.redis_pool).
156
173
  push("class" => "NotAKnownClass", "args" => [])
157
174
  }.to_not raise_error
158
175
  end
data/spec/spec_helper.rb CHANGED
@@ -1,11 +1,6 @@
1
1
  require "rspec"
2
2
  require 'colorize'
3
3
  require 'sidekiq'
4
-
5
- # Celluloid should only be manually required before Sidekiq versions 4.+
6
- require 'sidekiq/version'
7
- require 'celluloid' if Gem::Version.new(Sidekiq::VERSION) < Gem::Version.new('4.0')
8
-
9
4
  require 'sidekiq/processor'
10
5
  require 'sidekiq/manager'
11
6
  require 'sidekiq-status'
@@ -50,7 +45,7 @@ def redis_thread messages_limit, *channels
50
45
  end
51
46
  end
52
47
  end
53
- puts "Returing from thread".cyan if ENV['DEBUG']
48
+ puts "Returning from thread".cyan if ENV['DEBUG']
54
49
  messages
55
50
  }
56
51
 
@@ -83,7 +78,7 @@ def redis_client_thread message_limit, *channels
83
78
  end
84
79
  end
85
80
  end
86
- puts "Returing from thread".cyan if ENV['DEBUG']
81
+ puts "Returning from thread".cyan if ENV['DEBUG']
87
82
  messages
88
83
  }
89
84
  sleep 0.1
@@ -126,7 +121,7 @@ def start_server server_middleware_options = {}
126
121
  # Launch
127
122
  puts "Server starting".yellow if ENV['DEBUG']
128
123
  instance = Sidekiq::CLI.instance
129
- instance.parse(['-r', File.expand_path('environment.rb', File.dirname(__FILE__))])
124
+ instance.parse(['-r', File.expand_path('test_environment.rb', File.dirname(__FILE__))])
130
125
  instance.run
131
126
 
132
127
  end
@@ -32,6 +32,17 @@ class LongJob < StubJob
32
32
  end
33
33
  end
34
34
 
35
+ class LongProgressJob < StubJob
36
+ def perform(*args)
37
+ sleep 0.25
38
+ 10.times do |i|
39
+ at i * 10
40
+ sleep (args[0] || 0.25) / 10.0
41
+ end
42
+ at 100
43
+ end
44
+ end
45
+
35
46
  class DataJob < StubJob
36
47
  def perform
37
48
  sleep 0.1
@@ -0,0 +1 @@
1
+ # This file has been intentionally left blank
@@ -0,0 +1,124 @@
1
+ .progress {
2
+ background-color: #C8E1ED;
3
+ border-radius: 8px;
4
+ height: 100%;
5
+ }
6
+ .progress-bar {
7
+ display: flex;
8
+ align-items: center;
9
+ }
10
+ .progress-percentage {
11
+ padding-left: 6px;
12
+ color: #333;
13
+ text-align: left;
14
+ text-shadow: 0 0 5px white;
15
+ font-weight: bold;
16
+ }
17
+ .bar {
18
+ background-color: #2897cb;
19
+ color: white;
20
+ text-shadow: 0 0 0;
21
+ }
22
+ .message {
23
+ text-shadow: 0 0 5px white;
24
+ font-weight: bold; padding-left: 4px;
25
+ color: #333;
26
+ }
27
+ .actions {
28
+ text-align: center;
29
+ }
30
+ .btn-warning {
31
+ background-image: linear-gradient(#f0ad4e, #eea236)
32
+ }
33
+ .index-header {
34
+ display: flex;
35
+ justify-content: space-between;
36
+ }
37
+ .nav-container {
38
+ display: flex;
39
+ line-height: 45px;
40
+ }
41
+ .nav-container .pull-right {
42
+ float: none !important;
43
+ }
44
+ .nav-container .pagination {
45
+ display: flex;
46
+ align-items: center;
47
+ }
48
+ .nav-container .per-page, .filter-status {
49
+ display: flex;
50
+ align-items: center;
51
+ margin: 20px 0 20px 10px;
52
+ white-space: nowrap;
53
+ }
54
+ .nav-container .per-page SELECT {
55
+ margin: 0 0 0 5px;
56
+ }
57
+ .status-header {
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: space-between;
61
+ }
62
+ .status-table th {
63
+ font-weight: bold;
64
+ width: 25%;
65
+ padding: 12px;
66
+ }
67
+ .status-table td {
68
+ padding: 12px;
69
+ vertical-align: top;
70
+ }
71
+ .status-table .timestamp {
72
+ font-family: monospace;
73
+ font-size: 13px;
74
+ }
75
+ .status-table .multiline {
76
+ white-space: pre-wrap;
77
+ font-family: monospace;
78
+ background-color: #f8f8f8;
79
+ padding: 8px;
80
+ border-radius: 3px;
81
+ }
82
+ .label {
83
+ display: inline-block;
84
+ }
85
+ .label-primary {
86
+ background-color: #337ab7;
87
+ }
88
+ .center {
89
+ text-align: center;
90
+ }
91
+ .nowrap {
92
+ white-space: nowrap;
93
+ }
94
+ .h-30px {
95
+ height: 30px;
96
+ }
97
+ .mb-0 {
98
+ margin-bottom: 0;
99
+ }
100
+ .fs-lg {
101
+ font-size: 1.4em;
102
+ }
103
+
104
+ /* Dark mode support */
105
+ @media screen and (prefers-color-scheme: dark) {
106
+ .progress {
107
+ background-color: #233240;
108
+ }
109
+
110
+ .progress-percentage {
111
+ color: #ecf0f1;
112
+ text-shadow: none;
113
+ }
114
+
115
+ .bar {
116
+ background-color: #3498db;
117
+ color: #ecf0f1;
118
+ }
119
+
120
+ .message {
121
+ color: #ecf0f1;
122
+ text-shadow: none;
123
+ }
124
+ }
@@ -0,0 +1,24 @@
1
+ // Handles the navigation for the filter and per-page select dropdowns
2
+ document.addEventListener("change", function(event) {
3
+ if (event.target.matches(".nav-container select.form-control")) {
4
+ window.location = event.target.options[event.target.selectedIndex].getAttribute('data-url')
5
+ }
6
+ })
7
+
8
+ // Set width of progress bars based on their aria-valuenow attribute
9
+ function updateProgressBarWidths() {
10
+ document.querySelectorAll('.progress-bar').forEach(function(progressBar) {
11
+ const valueNow = progressBar.getAttribute('aria-valuenow');
12
+ if (valueNow !== null) {
13
+ progressBar.style.width = valueNow + '%';
14
+ }
15
+ });
16
+ }
17
+ updateProgressBarWidths();
18
+
19
+ // Update progress bar widths when the page loads
20
+ document.addEventListener("DOMContentLoaded", updateProgressBarWidths);
21
+
22
+ // Also update when new content is dynamically loaded
23
+ document.addEventListener("DOMContentMounted", updateProgressBarWidths);
24
+