├── .rspec ├── spec ├── test_environment.rb ├── lib │ ├── sidekiq-status │ │ ├── testing_spec.rb │ │ ├── worker_spec.rb │ │ ├── client_middleware_spec.rb │ │ ├── web_spec.rb │ │ └── server_middleware_spec.rb │ └── sidekiq-status_spec.rb ├── environment.rb ├── support │ └── test_jobs.rb └── spec_helper.rb ├── Gemfile ├── .devcontainer ├── Dockerfile ├── docker-compose.yml ├── devcontainer.json └── README.md ├── lib ├── sidekiq-status │ ├── version.rb │ ├── sidekiq_extensions.rb │ ├── redis_client_adapter.rb │ ├── testing │ │ └── inline.rb │ ├── redis_adapter.rb │ ├── worker.rb │ ├── client_middleware.rb │ ├── helpers.rb │ ├── server_middleware.rb │ ├── storage.rb │ └── web.rb └── sidekiq-status.rb ├── web ├── sidekiq-status-web.png ├── sidekiq-status-single-web.png ├── views │ ├── status_not_found.erb │ ├── statuses.erb │ └── status.erb └── assets │ ├── statuses.js │ └── statuses.css ├── Dockerfile ├── gemfiles ├── sidekiq_7.x.gemfile ├── sidekiq_8.x.gemfile ├── sidekiq_7.0.gemfile ├── sidekiq_7.3.gemfile └── sidekiq_8.0.gemfile ├── .gitignore ├── docker-compose.yml ├── Appraisals ├── .gitlab-ci.yml ├── LICENSE ├── .github └── workflows │ └── ci.yaml ├── sidekiq-status.gemspec ├── CHANGELOG.md ├── Rakefile └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | 4 | -------------------------------------------------------------------------------- /spec/test_environment.rb: -------------------------------------------------------------------------------- 1 | # This file has been intentionally left blank 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # A sample Gemfile 2 | source "https://rubygems.org" 3 | 4 | gemspec 5 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/ruby:3.4 2 | WORKDIR /workspace 3 | -------------------------------------------------------------------------------- /lib/sidekiq-status/version.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Status 3 | VERSION = '4.0.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /web/sidekiq-status-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenaniah/sidekiq-status/HEAD/web/sidekiq-status-web.png -------------------------------------------------------------------------------- /web/sidekiq-status-single-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenaniah/sidekiq-status/HEAD/web/sidekiq-status-single-web.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # A very simple Dockerfile to allow us to run the test suite from docker compose 2 | FROM ruby:3.3.5 3 | WORKDIR /app 4 | COPY . . 5 | RUN bundle install 6 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_7.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 7" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_8.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 8" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 7.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_7.3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 7.3.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 8.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/sidekiq-status/sidekiq_extensions.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/version' 2 | 3 | module Sidekiq 4 | def self.major_version 5 | VERSION.split('.').first.to_i 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /web/views/status_not_found.erb: -------------------------------------------------------------------------------- 1 | <% require 'cgi'; def h(v); CGI.escape_html(v.to_s); end %> 2 |
| 31 | <%= h hdr[:name] %> 32 | | 33 | <% end %> 34 |35 | Actions 36 | | 37 |||||||
|---|---|---|---|---|---|---|---|
| 41 | 42 | | 43 |
44 | <%= h container["args"] %>
45 | |
46 | 47 | <%= h container["status"] %> 48 | | 49 | <% secs = Time.now.to_i - container["updated_at"].to_i %> 50 |"> 51 | <% if secs > 0 %> 52 | <%= ChronicDuration.output(secs, :weeks => true, :units => 2) %> ago 53 | <% else %> 54 | Now 55 | <% end %> 56 | | 57 |
58 |
59 |
64 |
65 | |
66 | 67 | <% unless container["elapsed"].nil? %> 68 | <%= ChronicDuration.output(container["elapsed"].to_i, :weeks => true, :units => 2) || '0 secs' %> 69 | <% end %> 70 | | 71 |72 | <% if container["eta"] %> 73 | <%= ChronicDuration.output(container["eta"].to_i, :weeks => true, :units => 2) %> 74 | <% end %> 75 | | 76 |
77 |
78 |
89 |
90 | |
91 |
| 96 | | |||||||
| Job ID | 35 |<%= h @status["jid"] %> |
36 |
|---|---|
| Job | 39 |<%= h @status["worker"] %> | 40 |
| Arguments | 43 |
44 | <% if @status["args"] && !@status["args"].empty? %>
45 | <% args_content = h(@status["args"]) %>
46 | <% if args_content.include?("\n") %>
47 | <%= args_content %>
48 | <% else %>
49 | <%= args_content %>
50 | <% end %>
51 | <% else %>
52 | none
53 | <% end %>
54 | |
55 |
| Status | 58 |<%= h @status["status"] %> | 59 |
| Elapsed Time | 62 |63 | <% unless @status["elapsed"].nil? %> 64 | <%= ChronicDuration.output(@status["elapsed"].to_i, :weeks => true, :units => 2) || '0 secs' %> 65 | <% else %> 66 | n/a 67 | <% end %> 68 | | 69 |
| Progress | 72 |73 | <%= @status["pct_complete"].to_i %>% 74 | <% if @status["at"] && @status["total"] %> 75 | (<%= @status["at"] %> of <%= @status["total"] %>) 76 | <% end %> 77 | | 78 |
| ETA | 82 |83 | <% if @status["eta"] %> 84 | <%= ChronicDuration.output(@status["eta"].to_i, :weeks => true, :units => 2) %> 85 | <% else %> 86 | n/a 87 | <% end %> 88 | | 89 |
| Message | 93 |94 | <% if @status["message"] && !@status["message"].empty? %> 95 | <%= h(@status["message"]) %> 96 | <% else %> 97 | none 98 | <% end %> 99 | | 100 |
| Enqueued At | 103 |<%= format_iso_timestamp(@status["enqueued_at"]) %> | 104 |
| Started At | 107 |<%= format_iso_timestamp(@status["started_at"]) %> | 108 |
| Last Updated | 111 |
112 | <%= format_iso_timestamp(@status["updated_at"]) %>
113 | <% if @status["updated_at"] %>
114 | 115 | <% secs = Time.now.to_i - @status["updated_at"].to_i %> 116 | <% if secs > 0 %> 117 | (<%= ChronicDuration.output(secs, :weeks => true, :units => 2) %> ago) 118 | <% else %> 119 | (now) 120 | <% end %> 121 | 122 | <% end %> 123 | |
124 |
| Ended At | 127 |<%= format_iso_timestamp(@status["ended_at"]) %> | 128 |
| <%= h key %> | 140 |
141 | <% if val && val.to_s.include?("\n") %>
142 | <%= h val %>
143 | <% else %>
144 | <%= h(val) || "none" %>
145 | <% end %>
146 | |
147 |
|---|
The Sidekiq web interface is available at /sidekiq
49 | 50 | 51 | HTML 52 | ] 53 | ] 54 | } 55 | end 56 | end 57 | 58 | puts "Starting server on http://localhost:9292" 59 | puts "Sidekiq web interface available at http://localhost:9292/sidekiq" 60 | puts "Press Ctrl+C to stop the server" 61 | 62 | # Use WEBrick with a proper Rack handler 63 | server = WEBrick::HTTPServer.new(Port: 9292, Host: '0.0.0.0') 64 | 65 | # Mount the Rack app properly 66 | server.mount_proc '/' do |req, res| 67 | begin 68 | # Construct proper Rack environment 69 | env = { 70 | 'REQUEST_METHOD' => req.request_method, 71 | 'PATH_INFO' => req.path_info || req.path, 72 | 'QUERY_STRING' => req.query_string || '', 73 | 'REQUEST_URI' => req.request_uri.to_s, 74 | 'HTTP_HOST' => req.host, 75 | 'SERVER_NAME' => req.host, 76 | 'SERVER_PORT' => req.port.to_s, 77 | 'SCRIPT_NAME' => '', 78 | 'rack.input' => StringIO.new(req.body || ''), 79 | 'rack.errors' => $stderr, 80 | 'rack.version' => [1, 3], 81 | 'rack.url_scheme' => 'http', 82 | 'rack.multithread' => true, 83 | 'rack.multiprocess' => false, 84 | 'rack.run_once' => false 85 | } 86 | 87 | # Add request headers to environment 88 | req.header.each do |key, values| 89 | env_key = key.upcase.tr('-', '_') 90 | env_key = "HTTP_#{env_key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(env_key) 91 | env[env_key] = values.first if values.any? 92 | end 93 | 94 | # Call the Rack app 95 | status, headers, body = app.call(env) 96 | 97 | # Set response 98 | res.status = status 99 | headers.each { |k, v| res[k] = v } if headers 100 | 101 | # Handle response body 102 | if body.respond_to?(:each) 103 | body_content = "" 104 | body.each { |chunk| body_content << chunk.to_s } 105 | res.body = body_content 106 | else 107 | res.body = body.to_s 108 | end 109 | 110 | rescue => e 111 | res.status = 500 112 | res['Content-Type'] = 'text/plain' 113 | res.body = "Internal Server Error: #{e.message}" 114 | puts "Error: #{e.message}\n#{e.backtrace.join("\n")}" 115 | end 116 | end 117 | 118 | trap('INT') { server.shutdown } 119 | 120 | begin 121 | server.start 122 | rescue Interrupt 123 | puts "\nServer stopped." 124 | end 125 | end 126 | 127 | desc "Starts an IRB session with Sidekiq, Sidekiq::Status, and the testing jobs loaded" 128 | task :irb do 129 | require 'irb' 130 | require 'sidekiq-status' 131 | require_relative 'spec/support/test_jobs' 132 | 133 | Sidekiq.configure_server do |config| 134 | Sidekiq::Status.configure_server_middleware config 135 | end 136 | 137 | # Configure Sidekiq if needed 138 | Sidekiq.configure_client do |config| 139 | Sidekiq::Status.configure_client_middleware config 140 | config.redis = { url: ENV['REDIS_URL'] || 'redis://localhost:6379' } 141 | end 142 | 143 | puts "="*60 144 | puts "IRB Session with Sidekiq Status" 145 | puts "" 146 | puts "To launch a sidekiq worker, run:" 147 | puts " bundle exec sidekiq -r ./spec/environment.rb" 148 | puts "" 149 | puts "="*60 150 | puts "Available job classes:" 151 | puts " StubJob, LongJob, DataJob, ProgressJob," 152 | puts " FailingJob, ExpiryJob, etc." 153 | puts "" 154 | puts "Example usage:" 155 | puts " job_id = StubJob.perform_async" 156 | puts " job_id = LongJob.perform_async(0.5)" 157 | puts " Sidekiq::Status.status(job_id)" 158 | puts " Sidekiq::Status.get_all" 159 | puts "="*60 160 | puts "" 161 | 162 | ARGV.clear # Clear ARGV to prevent IRB from trying to parse them 163 | IRB.start 164 | end 165 | -------------------------------------------------------------------------------- /lib/sidekiq-status/storage.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq::Status::Storage 2 | RESERVED_FIELDS=%w(status stop enqueued_at started_at updated_at ended_at).freeze 3 | BATCH_LIMIT = 500 4 | 5 | protected 6 | 7 | # Stores multiple values into a job's status hash, 8 | # sets last update time 9 | # @param [String] id job id 10 | # @param [Hash] status_updates updated values 11 | # @param [Integer] expiration optional expire time in seconds 12 | # @param [ConnectionPool] redis_pool optional redis connection pool 13 | # @return [String] Redis operation status code 14 | def store_for_id(id, status_updates, expiration = nil, redis_pool=nil) 15 | status_updates.transform_values!(&:to_s) 16 | redis_connection(redis_pool) do |conn| 17 | conn.multi do |pipeline| 18 | pipeline.hset key(id), 'updated_at', Time.now.to_i, *(status_updates.to_a.flatten(1)) 19 | pipeline.expire key(id), (expiration || Sidekiq::Status::DEFAULT_EXPIRY) 20 | pipeline.publish "status_updates", id 21 | end[0] 22 | end 23 | end 24 | 25 | # Stores job status and sets expiration time to it 26 | # only in case of :failed or :stopped job 27 | # @param [String] id job id 28 | # @param [Symbol] job status 29 | # @param [Integer] expiration optional expire time in seconds 30 | # @param [ConnectionPool] redis_pool optional redis connection pool 31 | # @return [String] Redis operation status code 32 | def store_status(id, status, expiration = nil, redis_pool=nil) 33 | updates = {status: status} 34 | case status.to_sym 35 | when :failed, :stopped, :interrupted, :complete 36 | updates[:ended_at] = Time.now.to_i 37 | when :working 38 | updates[:started_at] = Time.now.to_i 39 | when :queued 40 | updates[:enqueued_at] = Time.now.to_i 41 | end 42 | store_for_id id, updates, expiration, redis_pool 43 | end 44 | 45 | # Unschedules the job and deletes the Status 46 | # @param [String] id job id 47 | # @param [Num] job_unix_time, unix timestamp for the scheduled job 48 | def delete_and_unschedule(job_id, job_unix_time = nil) 49 | Sidekiq::Status.redis_adapter do |conn| 50 | scan_options = {offset: 0, conn: conn, start: (job_unix_time || '-inf'), end: (job_unix_time || '+inf')} 51 | 52 | while not (jobs = schedule_batch(scan_options)).empty? 53 | match = scan_scheduled_jobs_for_jid jobs, job_id 54 | unless match.nil? 55 | conn.zrem "schedule", match 56 | conn.del key(job_id) 57 | return true # Done 58 | end 59 | scan_options[:offset] += BATCH_LIMIT 60 | end 61 | end 62 | false 63 | end 64 | 65 | # Deletes status hash info for given job id 66 | # @param[String] job id 67 | # @retrun [Integer] number of keys that were removed 68 | def delete_status(id) 69 | redis_connection do |conn| 70 | conn.del(key(id)) 71 | end 72 | end 73 | 74 | # Gets a single valued from job status hash 75 | # @param [String] id job id 76 | # @param [String] Symbol field fetched field name 77 | # @return [String] Redis operation status code 78 | def read_field_for_id(id, field) 79 | Sidekiq::Status.redis_adapter do |conn| 80 | conn.hget(key(id), field) 81 | end 82 | end 83 | 84 | # Gets the whole status hash from the job status 85 | # @param [String] id job id 86 | # @return [Hash] Hash stored in redis 87 | def read_hash_for_id(id) 88 | Sidekiq::Status.redis_adapter do |conn| 89 | conn.hgetall(key(id)) 90 | end 91 | end 92 | 93 | private 94 | 95 | # Gets the batch of scheduled jobs based on input options 96 | # Uses Redis zrangebyscore for log(n) search, if unix-time is provided 97 | # @param [Hash] options, options hash containing (REQUIRED) keys: 98 | # - conn: Redis connection 99 | # - start: start score (i.e. -inf or a unix timestamp) 100 | # - end: end score (i.e. +inf or a unix timestamp) 101 | # - offset: current progress through (all) jobs (e.g.: 100 if you want jobs from 100 to BATCH_LIMIT) 102 | def schedule_batch(options) 103 | Sidekiq::Status.wrap_redis_connection(options[:conn]).schedule_batch("schedule", options.merge(limit: BATCH_LIMIT)) 104 | end 105 | 106 | # Searches the jobs Array for the job_id 107 | # @param [Array] scheduled_jobs, results of Redis schedule key 108 | # @param [String] id job id 109 | def scan_scheduled_jobs_for_jid(scheduled_jobs, job_id) 110 | # A Little skecthy, I know, but the structure of these internal JSON 111 | # is predefined in such a way where this will not catch unintentional elements, 112 | # and this is notably faster than performing JSON.parse() for every listing: 113 | scheduled_jobs.select { |job_listing| job_listing.match(/\"jid\":\"#{job_id}\"/) }[0] 114 | end 115 | 116 | # Yields redis connection. Uses redis pool if available. 117 | # @param [ConnectionPool] redis_pool optional redis connection pool 118 | def redis_connection(redis_pool=nil) 119 | if redis_pool 120 | redis_pool.with do |conn| 121 | yield conn 122 | end 123 | else 124 | Sidekiq.redis do |conn| 125 | yield conn 126 | end 127 | end 128 | end 129 | 130 | def key(id) 131 | "sidekiq:status:#{id}" 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/sidekiq-status/web.rb: -------------------------------------------------------------------------------- 1 | # adapted from https://github.com/cryo28/sidekiq_status 2 | require_relative 'helpers' 3 | 4 | module Sidekiq::Status 5 | # Hook into *Sidekiq::Web* Sinatra app which adds a new "/statuses" page 6 | module Web 7 | # Location of Sidekiq::Status::Web static assets and templates 8 | ROOT = File.expand_path("../../web", File.dirname(__FILE__)) 9 | VIEW_PATH = File.expand_path("views", ROOT) 10 | 11 | DEFAULT_PER_PAGE_OPTS = [25, 50, 100].freeze 12 | DEFAULT_PER_PAGE = 25 13 | 14 | class << self 15 | def per_page_opts= arr 16 | @per_page_opts = arr 17 | end 18 | def per_page_opts 19 | @per_page_opts || DEFAULT_PER_PAGE_OPTS 20 | end 21 | def default_per_page= val 22 | @default_per_page = val 23 | end 24 | def default_per_page 25 | @default_per_page || DEFAULT_PER_PAGE 26 | end 27 | end 28 | 29 | def self.registered(app) 30 | 31 | app.helpers Helpers 32 | 33 | app.get '/statuses' do 34 | 35 | jids = Sidekiq::Status.redis_adapter do |conn| 36 | conn.scan(match: 'sidekiq:status:*', count: 100).map do |key| 37 | key.split(':').last 38 | end.uniq 39 | end 40 | @statuses = [] 41 | 42 | jids.each do |jid| 43 | status = Sidekiq::Status::get_all jid 44 | next if !status || status.count < 2 45 | status = add_details_to_status(status) 46 | @statuses << status 47 | end 48 | 49 | sort_by = has_sort_by?(safe_url_params("sort_by")) ? safe_url_params("sort_by") : "updated_at" 50 | sort_dir = "asc" 51 | 52 | if safe_url_params("sort_dir") == "asc" 53 | @statuses = @statuses.sort { |x,y| (x[sort_by] <=> y[sort_by]) || -1 } 54 | else 55 | sort_dir = "desc" 56 | @statuses = @statuses.sort { |y,x| (x[sort_by] <=> y[sort_by]) || 1 } 57 | end 58 | 59 | if safe_url_params("status") && safe_url_params("status") != "all" 60 | @statuses = @statuses.select {|job_status| job_status["status"] == safe_url_params("status") } 61 | end 62 | 63 | # Sidekiq pagination 64 | @total_size = @statuses.count 65 | @count = safe_url_params("per_page") ? safe_url_params("per_page").to_i : Sidekiq::Status::Web.default_per_page 66 | @count = @total_size if safe_url_params("per_page") == 'all' 67 | @current_page = safe_url_params("page").to_i < 1 ? 1 : safe_url_params("page").to_i 68 | @statuses = @statuses.slice((@current_page - 1) * @count, @count) 69 | 70 | @headers = [ 71 | {id: "worker", name: "Worker / JID", class: nil, url: nil}, 72 | {id: "args", name: "Arguments", class: nil, url: nil}, 73 | {id: "status", name: "Status", class: nil, url: nil}, 74 | {id: "updated_at", name: "Last Updated", class: nil, url: nil}, 75 | {id: "pct_complete", name: "Progress", class: nil, url: nil}, 76 | {id: "elapsed", name: "Time Elapsed", class: nil, url: nil}, 77 | {id: "eta", name: "ETA", class: nil, url: nil}, 78 | ] 79 | 80 | args = request.params 81 | 82 | @headers.each do |h| 83 | h[:url] = "statuses?" + args.merge("sort_by" => h[:id], "sort_dir" => (sort_by == h[:id] && sort_dir == "asc") ? "desc" : "asc").map{|k, v| "#{k}=#{CGI.escape v.to_s}"}.join("&") 84 | h[:class] = "sorted_#{sort_dir}" if sort_by == h[:id] 85 | end 86 | 87 | erb(sidekiq_status_template(:statuses)) 88 | end 89 | 90 | app.get '/statuses/:jid' do 91 | job = Sidekiq::Status::get_all safe_route_params(:jid) 92 | 93 | if job.empty? 94 | throw :halt, [404, {"Content-Type" => "text/html"}, [erb(sidekiq_status_template(:status_not_found))]] 95 | else 96 | @status = add_details_to_status(job) 97 | erb(sidekiq_status_template(:status)) 98 | end 99 | end 100 | 101 | # Handles POST requests with method override for statuses 102 | app.post '/statuses' do 103 | case safe_url_params("_method") 104 | when 'put' 105 | # Retries a failed job from the status list 106 | retry_job_action 107 | when 'delete' 108 | # Removes a completed job from the status list 109 | delete_job_action 110 | else 111 | throw :halt, [405, {"Content-Type" => "text/html"}, ["Method not allowed"]] 112 | end 113 | end 114 | 115 | # Retries a failed job from the status list 116 | app.put '/statuses' do 117 | retry_job_action 118 | end 119 | 120 | # Removes a completed job from the status list 121 | app.delete '/statuses' do 122 | delete_job_action 123 | end 124 | end 125 | end 126 | end 127 | 128 | unless defined?(Sidekiq::Web) 129 | require 'sidekiq/web' 130 | end 131 | 132 | if Sidekiq.major_version >= 8 133 | Sidekiq::Web.configure do |config| 134 | config.register_extension( 135 | Sidekiq::Status::Web, 136 | name: 'statuses', 137 | tab: ['Statuses'], 138 | index: ['statuses'], 139 | root_dir: Sidekiq::Status::Web::ROOT, 140 | asset_paths: ['javascripts', 'stylesheets'] 141 | ) 142 | end 143 | elsif Gem::Version.new(Sidekiq::VERSION) >= Gem::Version.new('7.3.0') 144 | Sidekiq::Web.configure do |config| 145 | config.register( 146 | Sidekiq::Status::Web, 147 | name: 'statuses', 148 | tab: ['Statuses'], 149 | index: 'statuses' 150 | ) 151 | end 152 | else 153 | Sidekiq::Web.register(Sidekiq::Status::Web) 154 | Sidekiq::Web.tabs["Statuses"] = "statuses" 155 | end 156 | 157 | ["per_page", "sort_by", "sort_dir", "status"].each do |key| 158 | Sidekiq::WebHelpers::SAFE_QPARAMS.push(key) 159 | end 160 | 161 | # Register custom JavaScript and CSS assets 162 | ASSETS_PATH = File.expand_path('../../../web', __FILE__) 163 | 164 | Sidekiq::Web.use Rack::Static, 165 | urls: ['/assets'], 166 | root: ASSETS_PATH, 167 | cascade: true, 168 | header_rules: [[:all, { 'cache-control' => 'private, max-age=86400' }]] 169 | -------------------------------------------------------------------------------- /spec/lib/sidekiq-status/server_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Sidekiq::Status::ServerMiddleware do 4 | 5 | let!(:redis) { Sidekiq.redis { |conn| conn } } 6 | let!(:job_id) { SecureRandom.hex(12) } 7 | 8 | describe "without :expiration parameter" do 9 | it "sets working/complete status" do 10 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 11 | start_server do 12 | thread = branched_redis_thread 4, "status_updates", "job_messages_#{job_id}" do 13 | expect(ConfirmationJob.perform_async 'arg1' => 'val1').to eq(job_id) 14 | end 15 | expect(thread.value).to eq([ 16 | job_id, 17 | job_id, 18 | "while in #perform, status = working", 19 | job_id 20 | ]) 21 | end 22 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('complete') 23 | expect(Sidekiq::Status::complete?(job_id)).to be_truthy 24 | end 25 | 26 | it "sets failed status" do 27 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 28 | start_server do 29 | expect(capture_status_updates(3) { 30 | expect(FailingJob.perform_async).to eq(job_id) 31 | }).to eq([job_id]*3) 32 | end 33 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('failed') 34 | expect(Sidekiq::Status::failed?(job_id)).to be_truthy 35 | end 36 | 37 | it "sets failed status when Exception raised" do 38 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 39 | start_server do 40 | expect(capture_status_updates(3) { 41 | expect(FailingHardJob.perform_async).to eq(job_id) 42 | }).to eq([job_id]*3) 43 | end 44 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('failed') 45 | expect(Sidekiq::Status::failed?(job_id)).to be_truthy 46 | end 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 | 61 | context "when Sidekiq::Status::Worker is not included in the job" do 62 | it "should not set a failed status" do 63 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 64 | start_server do 65 | expect(FailingNoStatusJob.perform_async).to eq(job_id) 66 | end 67 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 68 | end 69 | 70 | it "should not set any status when Exception raised" do 71 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 72 | start_server do 73 | expect(FailingHardNoStatusJob.perform_async).to eq(job_id) 74 | end 75 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 76 | end 77 | 78 | it "should not set any status on system exit signal" do 79 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 80 | start_server do 81 | expect(ExitedNoStatusJob.perform_async).to eq(job_id) 82 | end 83 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 84 | end 85 | 86 | it "should not set any status on interrupt signal" do 87 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 88 | start_server do 89 | expect(InterruptedNoStatusJob.perform_async).to eq(job_id) 90 | end 91 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 92 | end 93 | end 94 | 95 | context "sets interrupted status" do 96 | it "on system exit signal" do 97 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 98 | start_server do 99 | expect(capture_status_updates(3) { 100 | expect(ExitedJob.perform_async).to eq(job_id) 101 | }).to eq([job_id]*3) 102 | end 103 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('interrupted') 104 | expect(Sidekiq::Status::interrupted?(job_id)).to be_truthy 105 | end 106 | 107 | it "on interrupt signal" do 108 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 109 | start_server do 110 | expect(capture_status_updates(3) { 111 | expect(InterruptedJob.perform_async).to eq(job_id) 112 | }).to eq([job_id]*3) 113 | end 114 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('interrupted') 115 | expect(Sidekiq::Status::interrupted?(job_id)).to be_truthy 116 | end 117 | 118 | end 119 | 120 | it "sets status hash ttl" do 121 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 122 | start_server do 123 | expect(StubJob.perform_async 'arg1' => 'val1').to eq(job_id) 124 | end 125 | expect(1..Sidekiq::Status::DEFAULT_EXPIRY).to cover redis.ttl("sidekiq:status:#{job_id}") 126 | end 127 | end 128 | 129 | describe "with :expiration parameter" do 130 | let(:huge_expiration) { Sidekiq::Status::DEFAULT_EXPIRY * 100 } 131 | before do 132 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 133 | end 134 | 135 | it "overwrites default expiry value" do 136 | start_server(:expiration => huge_expiration) do 137 | StubJob.perform_async 'arg1' => 'val1' 138 | end 139 | expect((Sidekiq::Status::DEFAULT_EXPIRY-1)..huge_expiration).to cover redis.ttl("sidekiq:status:#{job_id}") 140 | end 141 | 142 | it "can be overwritten by worker expiration method" do 143 | overwritten_expiration = huge_expiration * 100 144 | allow_any_instance_of(StubJob).to receive(:expiration).and_return(overwritten_expiration) 145 | start_server(:expiration => huge_expiration) do 146 | StubJob.perform_async 'arg1' => 'val1' 147 | end 148 | expect((huge_expiration+1)..overwritten_expiration).to cover redis.ttl("sidekiq:status:#{job_id}") 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/lib/sidekiq-status_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Sidekiq::Status do 4 | 5 | let!(:redis) { Sidekiq.redis { |conn| conn } } 6 | let!(:job_id) { SecureRandom.hex(12) } 7 | let!(:job_id_1) { SecureRandom.hex(12) } 8 | let!(:unused_id) { SecureRandom.hex(12) } 9 | let!(:plain_sidekiq_job_id) { SecureRandom.hex(12) } 10 | let!(:retried_job_id) { SecureRandom.hex(12) } 11 | let!(:retry_and_fail_job_id) { SecureRandom.hex(12) } 12 | 13 | describe ".status, .working?, .complete?" do 14 | it "gets job status by id as symbol" do 15 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 16 | 17 | start_server do 18 | expect(capture_status_updates(2) { 19 | expect(LongJob.perform_async(0.5)).to eq(job_id) 20 | }).to eq([job_id]*2) 21 | expect(Sidekiq::Status.status(job_id)).to eq(:working) 22 | expect(Sidekiq::Status.working?(job_id)).to be_truthy 23 | expect(Sidekiq::Status::queued?(job_id)).to be_falsey 24 | expect(Sidekiq::Status::retrying?(job_id)).to be_falsey 25 | expect(Sidekiq::Status::failed?(job_id)).to be_falsey 26 | expect(Sidekiq::Status::complete?(job_id)).to be_falsey 27 | expect(Sidekiq::Status::stopped?(job_id)).to be_falsey 28 | expect(Sidekiq::Status::interrupted?(job_id)).to be_falsey 29 | end 30 | expect(Sidekiq::Status.status(job_id)).to eq(:complete) 31 | expect(Sidekiq::Status.complete?(job_id)).to be_truthy 32 | end 33 | end 34 | 35 | describe ".get" do 36 | it "gets a single value from data hash as string" do 37 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 38 | 39 | start_server do 40 | expect(capture_status_updates(3) { 41 | expect(DataJob.perform_async).to eq(job_id) 42 | }).to eq([job_id]*3) 43 | expect(Sidekiq::Status.get(job_id, :status)).to eq('working') 44 | end 45 | expect(Sidekiq::Status.get(job_id, :data)).to eq('meow') 46 | end 47 | end 48 | 49 | describe ".at, .total, .pct_complete, .message" do 50 | it "should return job progress with correct type to it" do 51 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 52 | 53 | start_server do 54 | expect(capture_status_updates(4) { 55 | expect(ProgressJob.perform_async).to eq(job_id) 56 | }).to eq([job_id]*4) 57 | end 58 | expect(Sidekiq::Status.at(job_id)).to be(100) 59 | expect(Sidekiq::Status.total(job_id)).to be(500) 60 | # It returns a float therefor we need eq() 61 | expect(Sidekiq::Status.pct_complete(job_id)).to eq(20) 62 | expect(Sidekiq::Status.message(job_id)).to eq('howdy, partner?') 63 | end 64 | end 65 | 66 | describe ".get_all" do 67 | it "gets the job hash by id" do 68 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 69 | 70 | start_server do 71 | expect(capture_status_updates(2) { 72 | expect(LongJob.perform_async(0.5)).to eq(job_id) 73 | }).to eq([job_id]*2) 74 | expect(hash = Sidekiq::Status.get_all(job_id)).to include 'status' => 'working' 75 | expect(hash).to include 'started_at' 76 | expect(hash).to include 'updated_at' 77 | end 78 | expect(hash = Sidekiq::Status.get_all(job_id)).to include 'status' => 'complete' 79 | expect(hash).to include 'started_at' 80 | expect(hash).to include 'updated_at' 81 | expect(hash).to include 'ended_at' 82 | end 83 | end 84 | 85 | describe '.delete' do 86 | it 'deletes the status hash for given job id' do 87 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 88 | start_server do 89 | expect(capture_status_updates(2) { 90 | expect(LongJob.perform_async(0.5)).to eq(job_id) 91 | }).to eq([job_id]*2) 92 | end 93 | expect(Sidekiq::Status.delete(job_id)).to eq(1) 94 | end 95 | 96 | it 'should not raise error while deleting status hash if invalid job id' do 97 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 98 | expect(Sidekiq::Status.delete(job_id)).to eq(0) 99 | end 100 | end 101 | 102 | describe ".cancel" do 103 | it "cancels a job by id" do 104 | allow(SecureRandom).to receive(:hex).twice.and_return(job_id, job_id_1) 105 | start_server do 106 | job = LongJob.perform_in(3600) 107 | expect(job).to eq(job_id) 108 | second_job = LongJob.perform_in(3600) 109 | expect(second_job).to eq(job_id_1) 110 | 111 | initial_schedule = redis.zrange "schedule", 0, -1, withscores: true 112 | expect(initial_schedule.size).to be(2) 113 | expect(initial_schedule.select {|scheduled_job| JSON.parse(scheduled_job[0])["jid"] == job_id }.size).to be(1) 114 | 115 | expect(Sidekiq::Status.unschedule(job_id)).to be_truthy 116 | # Unused, therefore unfound => false 117 | expect(Sidekiq::Status.cancel(unused_id)).to be_falsey 118 | 119 | remaining_schedule = redis.zrange "schedule", 0, -1, withscores: true 120 | expect(remaining_schedule.size).to be(initial_schedule.size - 1) 121 | expect(remaining_schedule.select {|scheduled_job| JSON.parse(scheduled_job[0])["jid"] == job_id }.size).to be(0) 122 | end 123 | end 124 | 125 | it "does not cancel a job with correct id but wrong time" do 126 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 127 | start_server do 128 | scheduled_time = Time.now.to_i + 3600 129 | returned_job_id = LongJob.perform_at(scheduled_time) 130 | expect(returned_job_id).to eq(job_id) 131 | 132 | initial_schedule = redis.zrange "schedule", 0, -1, withscores: true 133 | expect(initial_schedule.size).to be(1) 134 | # wrong time, therefore unfound => false 135 | expect(Sidekiq::Status.cancel(returned_job_id, (scheduled_time + 1))).to be_falsey 136 | expect((redis.zrange "schedule", 0, -1, withscores: true).size).to be(1) 137 | # same id, same time, deletes 138 | expect(Sidekiq::Status.cancel(returned_job_id, (scheduled_time))).to be_truthy 139 | expect(redis.zrange "schedule", 0, -1, withscores: true).to be_empty 140 | end 141 | end 142 | end 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 | 158 | context "keeps normal Sidekiq functionality" do 159 | let(:expiration_param) { nil } 160 | 161 | it "does jobs with and without included worker module" do 162 | seed_secure_random_with_job_ids 163 | run_2_jobs! 164 | expect_2_jobs_are_done_and_status_eq :complete 165 | expect_2_jobs_ttl_covers 1..Sidekiq::Status::DEFAULT_EXPIRY 166 | end 167 | 168 | it "does jobs without a known class" do 169 | seed_secure_random_with_job_ids 170 | start_server(:expiration => expiration_param) do 171 | expect { 172 | Sidekiq::Client.new(pool: Sidekiq.redis_pool). 173 | push("class" => "NotAKnownClass", "args" => []) 174 | }.to_not raise_error 175 | end 176 | end 177 | 178 | it "retries failed jobs" do 179 | allow(SecureRandom).to receive(:hex).exactly(3).times.and_return(retried_job_id) 180 | start_server do 181 | expect(capture_status_updates(3) { 182 | expect(RetriedJob.perform_async()).to eq(retried_job_id) 183 | }).to eq([retried_job_id] * 3) 184 | expect(Sidekiq::Status.status(retried_job_id)).to eq(:retrying) 185 | expect(Sidekiq::Status.working?(retried_job_id)).to be_falsey 186 | expect(Sidekiq::Status::queued?(retried_job_id)).to be_falsey 187 | expect(Sidekiq::Status::retrying?(retried_job_id)).to be_truthy 188 | expect(Sidekiq::Status::failed?(retried_job_id)).to be_falsey 189 | expect(Sidekiq::Status::complete?(retried_job_id)).to be_falsey 190 | expect(Sidekiq::Status::stopped?(retried_job_id)).to be_falsey 191 | expect(Sidekiq::Status::interrupted?(retried_job_id)).to be_falsey 192 | end 193 | expect(Sidekiq::Status.status(retried_job_id)).to eq(:retrying) 194 | expect(Sidekiq::Status::retrying?(retried_job_id)).to be_truthy 195 | 196 | # restarting and waiting for the job to complete 197 | start_server do 198 | expect(capture_status_updates(3) {}).to eq([retried_job_id] * 3) 199 | expect(Sidekiq::Status.status(retried_job_id)).to eq(:complete) 200 | expect(Sidekiq::Status.complete?(retried_job_id)).to be_truthy 201 | expect(Sidekiq::Status::retrying?(retried_job_id)).to be_falsey 202 | end 203 | end 204 | 205 | it "marks retried jobs as failed once they do eventually fail" do 206 | allow(SecureRandom).to receive(:hex).and_return(retry_and_fail_job_id) 207 | start_server do 208 | expect( 209 | capture_status_updates(3) { 210 | expect(RetryAndFailJob.perform_async).to eq(retry_and_fail_job_id) 211 | } 212 | ).to eq([retry_and_fail_job_id] * 3) 213 | 214 | expect(Sidekiq::Status.status(retry_and_fail_job_id)).to eq(:retrying) 215 | end 216 | 217 | # restarting and waiting for the job to fail 218 | start_server do 219 | expect(capture_status_updates(3) {}).to eq([retry_and_fail_job_id] * 3) 220 | 221 | expect(Sidekiq::Status.status(retry_and_fail_job_id)).to eq(:failed) 222 | expect(Sidekiq::Status.failed?(retry_and_fail_job_id)).to be_truthy 223 | expect(Sidekiq::Status::retrying?(retry_and_fail_job_id)).to be_falsey 224 | end 225 | end 226 | 227 | context ":expiration param" do 228 | before { seed_secure_random_with_job_ids } 229 | let(:expiration_param) { Sidekiq::Status::DEFAULT_EXPIRY * 100 } 230 | 231 | it "allow to overwrite :expiration parameter" do 232 | run_2_jobs! 233 | expect_2_jobs_are_done_and_status_eq :complete 234 | expect_2_jobs_ttl_covers (Sidekiq::Status::DEFAULT_EXPIRY+1)..expiration_param 235 | end 236 | 237 | it "allow to overwrite :expiration parameter by #expiration method from worker" do 238 | overwritten_expiration = expiration_param * 100 239 | allow_any_instance_of(NoStatusConfirmationJob).to receive(:expiration). 240 | and_return(overwritten_expiration) 241 | allow_any_instance_of(StubJob).to receive(:expiration). 242 | and_return(overwritten_expiration) 243 | run_2_jobs! 244 | expect_2_jobs_are_done_and_status_eq :complete 245 | expect_2_jobs_ttl_covers (expiration_param+1)..overwritten_expiration 246 | end 247 | 248 | it "reads #expiration from a method when defined" do 249 | allow(SecureRandom).to receive(:hex).once.and_return(job_id, job_id_1) 250 | start_server do 251 | expect(StubJob.perform_async).to eq(job_id) 252 | expect(ExpiryJob.perform_async).to eq(job_id_1) 253 | expect(redis.ttl("sidekiq:status:#{job_id}")).to eq(30 * 60) 254 | expect(redis.ttl("sidekiq:status:#{job_id_1}")).to eq(15) 255 | end 256 | end 257 | end 258 | 259 | def seed_secure_random_with_job_ids 260 | allow(SecureRandom).to receive(:hex).exactly(4).times. 261 | and_return(plain_sidekiq_job_id, plain_sidekiq_job_id, job_id_1, job_id_1) 262 | end 263 | 264 | def run_2_jobs! 265 | start_server(:expiration => expiration_param) do 266 | expect(capture_status_updates(6) { 267 | expect(StubJob.perform_async).to eq(plain_sidekiq_job_id) 268 | NoStatusConfirmationJob.perform_async(1) 269 | expect(StubJob.perform_async).to eq(job_id_1) 270 | NoStatusConfirmationJob.perform_async(2) 271 | }).to match_array([plain_sidekiq_job_id, job_id_1] * 3) 272 | end 273 | end 274 | 275 | def expect_2_jobs_ttl_covers(range) 276 | expect(range).to cover redis.ttl("sidekiq:status:#{plain_sidekiq_job_id}") 277 | expect(range).to cover redis.ttl("sidekiq:status:#{job_id_1}") 278 | end 279 | 280 | def expect_2_jobs_are_done_and_status_eq(status) 281 | expect(redis.mget('NoStatusConfirmationJob_1', 'NoStatusConfirmationJob_2')).to eq(%w(done)*2) 282 | expect(Sidekiq::Status.status(plain_sidekiq_job_id)).to eq(status) 283 | expect(Sidekiq::Status.status(job_id_1)).to eq(status) 284 | end 285 | end 286 | 287 | end 288 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sidekiq::Status 2 | [](https://badge.fury.io/rb/sidekiq-status) 3 | [](https://github.com/kenaniah/sidekiq-status/actions/) 4 | 5 | Sidekiq-status is an extension to [Sidekiq](https://github.com/mperham/sidekiq) that tracks information about your Sidekiq and provides a UI to that purpose. It was inspired by [resque-status](https://github.com/quirkey/resque-status). 6 | 7 | Supports Ruby 3.2+ and Sidekiq 7.0+ or newer. 8 | 9 | ## Table of Contents 10 | 11 | - [Installation](#installation) 12 | - [Migration Guides](#migration-guides) 13 | - [Migrating to Version 4.x from 3.x](#migrating-to-version-4x-from-3x) 14 | - [Migrating to Version 3.x from 2.x](#migrating-to-version-3x-from-2x) 15 | - [Setup Checklist](#setup-checklist) 16 | - [Configuration](#configuration) 17 | - [Expiration Times](#expiration-times) 18 | - [Retrieving Status](#retrieving-status) 19 | - [ActiveJob Support](#activejob-support) 20 | - [Tracking Progress and Storing Data](#tracking-progress-and-storing-data) 21 | - [Stopping a Running Job](#stopping-a-running-job) 22 | - [Unscheduling Jobs](#unscheduling) 23 | - [Deleting Job Status](#deleting-job-status-by-job-id) 24 | - [Sidekiq Web Integration](#sidekiq-web-integration) 25 | - [Testing](#testing) 26 | - [Development Environment](#development-environment) 27 | - [Testing with Appraisal](#testing-with-appraisal) 28 | - [Troubleshooting](#troubleshooting) 29 | - [Contributing](#contributing) 30 | 31 | ## Installation 32 | 33 | Add this line to your application's Gemfile: 34 | 35 | ```ruby 36 | gem 'sidekiq-status' 37 | ``` 38 | 39 | Or install it yourself as: 40 | 41 | ```bash 42 | gem install sidekiq-status 43 | ``` 44 | 45 | ## Migration Guides 46 | 47 | ### Migrating to Version 4.x from 3.x 48 | 49 | Version 4.0.0 adds support for Ruby 3.3, 3.4 and Sidekiq 8.x, but drops support for Sidekiq 6.x and Ruby versions that are now end-of-life (specifically, Ruby 2.7.x - Ruby 3.1.x). 50 | 51 | Version 4.0.0 introduces a breaking change in the way job timestamps are stored in Redis, and also renames `#working_at` to `#updated_at`. Additionally, this version includes major UI improvements with enhanced progress bars and better web interface styling. 52 | 53 | ### Migrating to Version 3.x from 2.x 54 | 55 | Version 3.0.0 adds support for Sidekiq 7.x, but drops support for Sidekiq 5.x. **You should be able to upgrade cleanly from version 2.x to 3.x provided you are running Sidekiq 6.x or newer.** 56 | 57 | #### Migrating to Version 2.x from 1.x 58 | 59 | Version 2.0.0 was published in order to add support for Ruby 3.0 and Sidekiq 6.x and to remove support for versions of both that are now end-of-life. **You should be able to upgrade cleanly from version 1.x to 2.x provided you are running Sidekiq 5.x or newer.** 60 | 61 | Sidekiq-status version 1.1.4 provides support all the way back to Sidekiq 3.x and was maintained at https://github.com/utgarda/sidekiq-status/. 62 | 63 | ## Setup Checklist 64 | 65 | To get started: 66 | 67 | * [Configure](#configuration) the middleware 68 | * (Optionally) add the [web interface](#adding-the-web-interface) 69 | * (Optionally) enable support for [ActiveJob](#activejob-support) 70 | 71 | ### Configuration 72 | 73 | To use, add sidekiq-status to the middleware chains. See [Middleware usage](https://github.com/mperham/sidekiq/wiki/Middleware) 74 | on the Sidekiq wiki for more info. 75 | 76 | ``` ruby 77 | require 'sidekiq' 78 | require 'sidekiq-status' 79 | 80 | Sidekiq.configure_client do |config| 81 | # accepts :expiration (optional) 82 | Sidekiq::Status.configure_client_middleware config, expiration: 30.minutes.to_i 83 | end 84 | 85 | Sidekiq.configure_server do |config| 86 | # accepts :expiration (optional) 87 | Sidekiq::Status.configure_server_middleware config, expiration: 30.minutes.to_i 88 | 89 | # accepts :expiration (optional) 90 | Sidekiq::Status.configure_client_middleware config, expiration: 30.minutes.to_i 91 | end 92 | ``` 93 | 94 | Include the `Sidekiq::Status::Worker` module in your jobs if you want the additional functionality of tracking progress and storing / retrieving job data. 95 | 96 | ``` ruby 97 | class MyJob 98 | include Sidekiq::Worker 99 | include Sidekiq::Status::Worker # enables job status tracking 100 | 101 | def perform(*args) 102 | # your code goes here 103 | end 104 | end 105 | ``` 106 | 107 | Note: _only jobs that include `Sidekiq::Status::Worker`_ will have their statuses tracked. 108 | 109 | To overwrite expiration on a per-worker basis, write an expiration method like the one below: 110 | 111 | ``` ruby 112 | class MyJob 113 | include Sidekiq::Worker 114 | include Sidekiq::Status::Worker # enables job status tracking 115 | 116 | def expiration 117 | @expiration ||= 60 * 60 * 24 * 30 # 30 days 118 | end 119 | 120 | def perform(*args) 121 | # your code goes here 122 | end 123 | end 124 | ``` 125 | 126 | The job status and any additional stored details will remain in Redis until the expiration time is reached. It is recommended that you find an expiration time that works best for your workload. 127 | 128 | ### Expiration Times 129 | 130 | As sidekiq-status stores information about jobs in Redis, it is necessary to set an expiration time for the data that gets stored. A default expiration time may be configured at the time the middleware is loaded via the `:expiration` parameter. 131 | 132 | As explained above, the default expiration may also be overridden on a per-job basis by defining it within the job itself via a method called `#expiration`. 133 | 134 | The expiration time set will be used as the [Redis expire time](https://redis.io/commands/expire), which is also known as the TTL (time to live). Once the expiration time has passed, all information about the job's status and any custom data stored via sidekiq-status will disappear. 135 | 136 | It is advised that you set the expiration time greater than the amount of time required to complete the job. 137 | 138 | The default expiration time is 30 minutes. 139 | 140 | ### Retrieving Status 141 | 142 | Query for job status at any time up to expiration: 143 | 144 | ```ruby 145 | job_id = MyJob.perform_async(*args) 146 | ``` 147 | 148 | #### Basic Status Queries 149 | 150 | ```ruby 151 | # Get current status as symbol 152 | status = Sidekiq::Status.status(job_id) 153 | # Returns: :queued, :working, :retrying, :complete, :failed, :stopped, :interrupted, or nil after expiry 154 | 155 | # Check specific status with boolean methods 156 | Sidekiq::Status.queued?(job_id) # true if job is queued 157 | Sidekiq::Status.working?(job_id) # true if job is currently running 158 | Sidekiq::Status.retrying?(job_id) # true if job is retrying after failure 159 | Sidekiq::Status.complete?(job_id) # true if job completed successfully 160 | Sidekiq::Status.failed?(job_id) # true if job failed permanently 161 | Sidekiq::Status.interrupted?(job_id) # true if job was interrupted 162 | Sidekiq::Status.stopped?(job_id) # true if job was manually stopped 163 | ``` 164 | 165 | #### Progress and Completion 166 | 167 | ```ruby 168 | # Get progress information 169 | Sidekiq::Status.at(job_id) # Current progress (e.g., 42) 170 | Sidekiq::Status.total(job_id) # Total items to process (e.g., 100) 171 | Sidekiq::Status.pct_complete(job_id) # Percentage complete (e.g., 42) 172 | Sidekiq::Status.message(job_id) # Current status message 173 | ``` 174 | 175 | #### Timing Information 176 | 177 | ```ruby 178 | # Get timing data (returns Unix timestamps as integers, or nil) 179 | Sidekiq::Status.enqueued_at(job_id) # When job was enqueued 180 | Sidekiq::Status.started_at(job_id) # When job started processing 181 | Sidekiq::Status.updated_at(job_id) # Last update time 182 | Sidekiq::Status.ended_at(job_id) # When job finished 183 | 184 | # Estimated time to completion (in seconds, or nil) 185 | Sidekiq::Status.eta(job_id) # Based on current progress rate 186 | ``` 187 | 188 | #### Custom Data Retrieval 189 | 190 | ```ruby 191 | # Get specific custom field 192 | Sidekiq::Status.get(job_id, :field_name) # Returns string or nil 193 | 194 | # Get all job data as hash 195 | data = Sidekiq::Status.get_all(job_id) 196 | # Returns: { 197 | # "status" => "working", 198 | # "updated_at" => "1640995200", 199 | # "enqueued_at" => "1640995100", 200 | # "started_at" => "1640995150", 201 | # "at" => "42", 202 | # "total" => "100", 203 | # "pct_complete" => "42", 204 | # "message" => "Processing...", 205 | # "custom_field" => "custom_value" 206 | # } 207 | ``` 208 | 209 | **Important:** All status methods return `nil` or `false` after the expiration time. 210 | 211 | ### ActiveJob Support 212 | 213 | This gem also supports ActiveJob jobs. Their status will be tracked automatically. 214 | 215 | To also enable job progress tracking and data storage features, simply add the `Sidekiq::Status::Worker` module to your base class, like below: 216 | 217 | ```ruby 218 | # app/jobs/application_job.rb 219 | class ApplicationJob < ActiveJob::Base 220 | include Sidekiq::Status::Worker 221 | end 222 | 223 | # app/jobs/my_job.rb 224 | class MyJob < ApplicationJob 225 | def perform(*args) 226 | # your code goes here 227 | end 228 | end 229 | ``` 230 | 231 | ### Tracking Progress and Storing Data 232 | 233 | Sidekiq-status provides comprehensive progress tracking and custom data storage capabilities for jobs that include the `Sidekiq::Status::Worker` module. 234 | 235 | #### Setting Progress 236 | 237 | ```ruby 238 | class MyJob 239 | include Sidekiq::Worker 240 | include Sidekiq::Status::Worker # Required for progress tracking 241 | 242 | def perform(*args) 243 | # Set total number of items to process 244 | total 100 245 | 246 | # Update progress throughout your job 247 | (1..100).each do |i| 248 | # Do some work here... 249 | sleep 0.1 250 | 251 | # Update progress with optional message 252 | at i, "Processing item #{i}" 253 | # This automatically calculates percentage: i/100 * 100 254 | end 255 | end 256 | end 257 | ``` 258 | 259 | #### Storing and Retrieving Custom Data 260 | 261 | ```ruby 262 | class MyJob 263 | include Sidekiq::Worker 264 | include Sidekiq::Status::Worker 265 | 266 | def perform(user_id, options = {}) 267 | # Store custom data associated with this job 268 | store user_id: user_id 269 | store options: options.to_json 270 | store phase: 'initialization' 271 | 272 | # Store multiple fields at once 273 | store( 274 | current_batch: 1, 275 | batch_size: 50, 276 | errors_count: 0 277 | ) 278 | 279 | # Retrieve stored data (always returns String or nil) 280 | stored_user_id = retrieve(:user_id) 281 | stored_options = JSON.parse(retrieve(:options) || '{}') 282 | 283 | # Update progress and custom data together 284 | 50.times do |i| 285 | # Do work... 286 | 287 | # Update progress with custom data 288 | at i, "Processing batch #{i}" 289 | store current_item: i, last_processed_at: Time.now.to_s 290 | end 291 | 292 | # Mark different phases 293 | store phase: 'cleanup' 294 | at 100, "Job completed successfully" 295 | end 296 | end 297 | 298 | # From outside the job, retrieve custom data 299 | job_id = MyJob.perform_async(123, { priority: 'high' }) 300 | 301 | # Get specific fields 302 | user_id = Sidekiq::Status.get(job_id, :user_id) #=> "123" 303 | phase = Sidekiq::Status.get(job_id, :phase) #=> "cleanup" 304 | errors = Sidekiq::Status.get(job_id, :errors_count) #=> "0" 305 | 306 | # Get all job data including progress and custom fields 307 | all_data = Sidekiq::Status.get_all(job_id) 308 | puts all_data['phase'] #=> "cleanup" 309 | puts all_data['current_batch'] #=> "1" 310 | puts all_data['pct_complete'] #=> "100" 311 | ``` 312 | 313 | #### Progress Tracking Patterns 314 | 315 | ```ruby 316 | class DataImportJob 317 | include Sidekiq::Worker 318 | include Sidekiq::Status::Worker 319 | 320 | def perform(file_path) 321 | # Example: Processing a CSV file 322 | csv_data = CSV.read(file_path) 323 | 324 | # Set total based on data size 325 | total csv_data.size 326 | 327 | csv_data.each_with_index do |row, index| 328 | begin 329 | # Process the row 330 | process_row(row) 331 | 332 | # Update progress 333 | at index + 1, "Processed row #{index + 1} of #{csv_data.size}" 334 | 335 | # Store running statistics 336 | store( 337 | processed_count: index + 1, 338 | last_processed_id: row['id'], 339 | success_rate: calculate_success_rate 340 | ) 341 | 342 | rescue => e 343 | # Log error but continue processing 344 | error_count = (retrieve(:error_count) || '0').to_i + 1 345 | store error_count: error_count, last_error: e.message 346 | end 347 | end 348 | end 349 | end 350 | 351 | # Monitor progress from outside 352 | job_id = DataImportJob.perform_async('data.csv') 353 | 354 | # Check progress periodically 355 | while !Sidekiq::Status.complete?(job_id) && !Sidekiq::Status.failed?(job_id) 356 | progress = Sidekiq::Status.pct_complete(job_id) 357 | message = Sidekiq::Status.message(job_id) 358 | errors = Sidekiq::Status.get(job_id, :error_count) || '0' 359 | 360 | puts "Progress: #{progress}% - #{message} (#{errors} errors)" 361 | sleep 1 362 | end 363 | ``` 364 | 365 | #### External Progress Updates 366 | 367 | You can also update job progress from outside the worker: 368 | 369 | ```ruby 370 | # Update progress for any job by ID 371 | job_id = MyJob.perform_async 372 | Sidekiq::Status.store_for_id(job_id, { 373 | external_update: Time.now.to_s, 374 | updated_by: 'external_system' 375 | }) 376 | ``` 377 | 378 | ### Stopping a running job 379 | 380 | You can ask a job to stop execution by calling `.stop!` with its job ID. The 381 | next time the job calls `.at` it will raise 382 | `Sidekiq::Status::Worker::Stopped`. It will not attempt to retry. 383 | 384 | ```ruby 385 | job_id = MyJob.perform_async 386 | Sidekiq::Status.stop! job_id #=> true 387 | Sidekiq::Status.status job_id #=> :stopped 388 | ``` 389 | 390 | Note this will not kill a running job that is stuck. The job must call `.at` 391 | for it to be stopped in this way. 392 | 393 | ### Unscheduling 394 | 395 | ```ruby 396 | scheduled_job_id = MyJob.perform_in 3600 397 | Sidekiq::Status.cancel scheduled_job_id #=> true 398 | # doesn't cancel running jobs, this is more like unscheduling, therefore an alias: 399 | Sidekiq::Status.unschedule scheduled_job_id #=> true 400 | 401 | # returns false if invalid or wrong scheduled_job_id is provided 402 | Sidekiq::Status.unschedule some_other_unschedule_job_id #=> false 403 | Sidekiq::Status.unschedule nil #=> false 404 | Sidekiq::Status.unschedule '' #=> false 405 | # Note: cancel and unschedule are alias methods. 406 | ``` 407 | Important: If you schedule a job and then try any of the status methods after the expiration time, the result will be either `nil` or `false`. The job itself will still be in Sidekiq's scheduled queue and will execute normally. Once the job is started at its scheduled time, sidekiq-status' job metadata will once again be added back to Redis and you will be able to get status info for the job until the expiration time. 408 | 409 | ### Deleting Job Status by Job ID 410 | 411 | Job status and metadata will automatically be removed from Redis once the expiration time is reached. But if you would like to remove job information from Redis prior to the TTL expiration, `Sidekiq::Status#delete` will do just that. Note that this will also remove any metadata that was stored with the job. 412 | 413 | ```ruby 414 | # returns number of keys/jobs that were removed 415 | Sidekiq::Status.delete(job_id) #=> 1 416 | Sidekiq::Status.delete(bad_job_id) #=> 0 417 | ``` 418 | 419 | ### Sidekiq Web Integration 420 | 421 | This gem provides a comprehensive extension to Sidekiq's web interface that allows you to monitor job statuses, progress, and custom data in real-time. 422 | 423 | #### Features 424 | 425 | - **Job Status Dashboard** at `/statuses` - View all tracked jobs 426 | - **Individual Job Details** at `/statuses/:job_id` - Detailed job information 427 | - **Real-time Progress Bars** - Visual progress indicators 428 | - **Custom Data Display** - View all stored job metadata 429 | - **Job Control Actions** - Stop, retry, or delete jobs 430 | - **Responsive Design** - Works on desktop and mobile 431 | - **Dark Mode Support** - Integrates with Sidekiq's theme 432 | 433 |  434 | 435 | The main statuses page shows: 436 | - Job ID and worker class 437 | - Current status with color coding 438 | - Progress bar with percentage complete 439 | - Elapsed time and ETA 440 | - Last updated timestamp 441 | - Custom actions (stop, retry, delete) 442 | 443 |  444 | 445 | The individual job page provides: 446 | - Complete job metadata 447 | - Custom data fields 448 | - Detailed timing information 449 | - Full progress history 450 | - Error messages (if failed) 451 | 452 | #### Adding the Web Interface 453 | 454 | To enable the web interface, require the web module after setting up Sidekiq Web: 455 | 456 | ```ruby 457 | require 'sidekiq/web' 458 | require 'sidekiq-status/web' 459 | 460 | # In Rails, add to config/routes.rb: 461 | mount Sidekiq::Web => '/sidekiq' 462 | ``` 463 | 464 | #### Configuration Options 465 | 466 | Customize the web interface behavior: 467 | 468 | ```ruby 469 | # Configure pagination (default: 25 per page) 470 | Sidekiq::Status::Web.default_per_page = 50 471 | Sidekiq::Status::Web.per_page_opts = [25, 50, 100, 200] 472 | 473 | # The web interface will show these options in a dropdown 474 | ``` 475 | 476 | #### Web Interface Security 477 | 478 | Since job data may contain sensitive information, secure the web interface: 479 | 480 | ```ruby 481 | # Example with HTTP Basic Auth 482 | Sidekiq::Web.use Rack::Auth::Basic do |username, password| 483 | ActiveSupport::SecurityUtils.secure_compare(username, ENV['SIDEKIQ_USERNAME']) && 484 | ActiveSupport::SecurityUtils.secure_compare(password, ENV['SIDEKIQ_PASSWORD']) 485 | end 486 | 487 | # Example with devise (Rails) 488 | authenticate :user, lambda { |u| u.admin? } do 489 | mount Sidekiq::Web => '/sidekiq' 490 | end 491 | ``` 492 | 493 | **Note:** Only jobs that include `Sidekiq::Status::Worker` will appear in the web interface. 494 | 495 | ### Testing 496 | 497 | Drawing analogy from [sidekiq testing by inlining](https://github.com/mperham/sidekiq/wiki/Testing#testing-workers-inline), 498 | `sidekiq-status` allows to bypass redis and return a stubbed `:complete` status. 499 | Since inlining your sidekiq worker will run it in-process, any exception it throws will make your test fail. 500 | It will also run synchronously, so by the time you get to query the job status, the job will have been completed 501 | successfully. 502 | In other words, you'll get the `:complete` status only if the job didn't fail. 503 | 504 | Inlining example: 505 | 506 | You can run Sidekiq workers inline in your tests by requiring the `sidekiq/testing/inline` file in your `{test,spec}_helper.rb`: 507 | 508 | ```ruby 509 | require 'sidekiq/testing/inline' 510 | ``` 511 | 512 | To use `sidekiq-status` inlining, require it too in your `{test,spec}_helper.rb`: 513 | 514 | ```ruby 515 | require 'sidekiq-status/testing/inline' 516 | ``` 517 | 518 | ## Troubleshooting 519 | 520 | ### Common Issues and Solutions 521 | 522 | #### Job Status Always Returns `nil` 523 | 524 | **Problem:** `Sidekiq::Status.status(job_id)` returns `nil` even for recent jobs. 525 | 526 | **Solutions:** 527 | 1. **Verify middleware configuration:** 528 | ```ruby 529 | # Make sure both client and server middleware are configured 530 | Sidekiq.configure_client do |config| 531 | Sidekiq::Status.configure_client_middleware config 532 | end 533 | 534 | Sidekiq.configure_server do |config| 535 | Sidekiq::Status.configure_server_middleware config 536 | Sidekiq::Status.configure_client_middleware config # Also needed in server 537 | end 538 | ``` 539 | 540 | 2. **Check if job includes the Worker module:** 541 | ```ruby 542 | class MyJob 543 | include Sidekiq::Worker 544 | include Sidekiq::Status::Worker # This is required! 545 | end 546 | ``` 547 | 548 | 3. **Verify Redis connection:** 549 | ```ruby 550 | # Test Redis connectivity 551 | Sidekiq.redis { |conn| conn.ping } # Should return "PONG" 552 | ``` 553 | 554 | #### Jobs Not Appearing in Web Interface 555 | 556 | **Problem:** Jobs are tracked but don't show up in `/sidekiq/statuses`. 557 | 558 | **Solutions:** 559 | 1. **Include the web module:** 560 | ```ruby 561 | require 'sidekiq/web' 562 | require 'sidekiq-status/web' # Must be after sidekiq/web 563 | ``` 564 | 565 | 2. **Check job worker includes status module:** 566 | ```ruby 567 | # Only jobs with this module appear in web interface 568 | include Sidekiq::Status::Worker 569 | ``` 570 | 571 | 3. **Verify Redis key existence:** 572 | ```ruby 573 | # Check if status keys exist in Redis 574 | Sidekiq.redis do |conn| 575 | keys = conn.scan(match: 'sidekiq:status:*', count: 100) 576 | puts "Found #{keys.size} status keys" 577 | end 578 | ``` 579 | 580 | #### Progress Not Updating 581 | 582 | **Problem:** Job progress stays at 0% or doesn't update. 583 | 584 | **Solutions:** 585 | 1. **Call `total` before `at`:** 586 | ```ruby 587 | def perform 588 | total 100 # Set total first 589 | at 1 # Then update progress 590 | end 591 | ``` 592 | 593 | 2. **Use numeric values:** 594 | ```ruby 595 | # Correct 596 | at 50, "Halfway done" 597 | 598 | # Wrong - will not calculate percentage correctly 599 | at "50", "Halfway done" 600 | ``` 601 | 602 | 3. **Check for exceptions:** 603 | ```ruby 604 | def perform 605 | total 100 606 | begin 607 | at 50 608 | rescue => e 609 | puts "Progress update failed: #{e.message}" 610 | end 611 | end 612 | ``` 613 | 614 | #### Memory Usage Growing Over Time 615 | 616 | **Problem:** Redis memory usage increases continuously. 617 | 618 | **Solutions:** 619 | 1. **Set appropriate expiration:** 620 | ```ruby 621 | # Configure shorter expiration for high-volume jobs 622 | Sidekiq::Status.configure_client_middleware config, expiration: 5.minutes.to_i 623 | ``` 624 | 625 | 2. **Clean up manually if needed:** 626 | ```ruby 627 | # Remove old status data 628 | Sidekiq.redis do |conn| 629 | old_keys = conn.scan(match: 'sidekiq:status:*').select do |key| 630 | conn.ttl(key) == -1 # Keys without expiration 631 | end 632 | conn.del(*old_keys) unless old_keys.empty? 633 | end 634 | ``` 635 | 636 | #### Version Compatibility Issues 637 | 638 | **Problem:** Errors after upgrading Sidekiq or Ruby versions. 639 | 640 | **Solutions:** 641 | 1. **Check version compatibility:** 642 | ```ruby 643 | # sidekiq-status 4.x requirements: 644 | # Ruby 3.2+ 645 | # Sidekiq 7.0+ 646 | 647 | puts "Ruby: #{RUBY_VERSION}" 648 | puts "Sidekiq: #{Sidekiq::VERSION}" 649 | ``` 650 | 651 | 2. **Update gemfile constraints:** 652 | ```ruby 653 | gem 'sidekiq', '~> 8.0' # Use compatible version 654 | gem 'sidekiq-status' # Latest version 655 | ``` 656 | 657 | 3. **Check for breaking changes:** 658 | - Version 4.x renamed `#working_at` to `#updated_at` 659 | - Timestamp storage format changed in 4.x 660 | 661 | #### ActiveJob Integration Issues 662 | 663 | **Problem:** ActiveJob jobs not being tracked. 664 | 665 | **Solutions:** 666 | 1. **Include module in base class:** 667 | ```ruby 668 | class ApplicationJob < ActiveJob::Base 669 | include Sidekiq::Status::Worker # Add to base class 670 | end 671 | ``` 672 | 673 | 2. **Verify Sidekiq adapter:** 674 | ```ruby 675 | # In config/application.rb or config/environments/production.rb 676 | config.active_job.queue_adapter = :sidekiq 677 | ``` 678 | 679 | #### Testing Issues 680 | 681 | **Problem:** Tests failing with status-related code. 682 | 683 | **Solutions:** 684 | 1. **Use testing inline mode:** 685 | ```ruby 686 | # In test helper 687 | require 'sidekiq/testing' 688 | require 'sidekiq-status/testing/inline' 689 | 690 | Sidekiq::Testing.inline! 691 | ``` 692 | 693 | 2. **Mock status calls in tests:** 694 | ```ruby 695 | # RSpec example 696 | allow(Sidekiq::Status).to receive(:status).and_return(:complete) 697 | allow(Sidekiq::Status).to receive(:pct_complete).and_return(100) 698 | ``` 699 | 700 | ### Performance Considerations 701 | 702 | #### High-Volume Job Optimization 703 | 704 | For applications processing thousands of jobs: 705 | 706 | ```ruby 707 | # Use longer expiration to reduce Redis operations 708 | Sidekiq::Status.configure_client_middleware config, expiration: 24.hours.to_i 709 | 710 | # Reduce progress update frequency 711 | class HighVolumeJob 712 | include Sidekiq::Worker 713 | include Sidekiq::Status::Worker 714 | 715 | def perform(items) 716 | total items.size 717 | 718 | items.each_with_index do |item, index| 719 | process_item(item) 720 | 721 | # Update progress every 100 items instead of every item 722 | if (index + 1) % 100 == 0 723 | at index + 1, "Processed #{index + 1} items" 724 | end 725 | end 726 | end 727 | end 728 | ``` 729 | 730 | #### Redis Optimization 731 | 732 | ```ruby 733 | # Use Redis pipelining for batch operations 734 | def batch_update_status(job_data) 735 | Sidekiq.redis do |conn| 736 | conn.pipelined do |pipeline| 737 | job_data.each do |job_id, data| 738 | pipeline.hmset("sidekiq:status:#{job_id}", data.flatten) 739 | end 740 | end 741 | end 742 | end 743 | ``` 744 | 745 | ### Getting Help 746 | 747 | If you're still experiencing issues: 748 | 749 | 1. **Check the logs:** Look for Redis connection errors or middleware loading issues 750 | 2. **Enable debug logging:** Add `Sidekiq.logger.level = Logger::DEBUG` 751 | 3. **Test with minimal example:** Create a simple job to isolate the problem 752 | 4. **Check GitHub issues:** Search for similar problems 753 | 5. **Create an issue:** Include Ruby/Sidekiq versions, configuration, and error messages 754 | 755 | ## Development Environment 756 | 757 | This project provides multiple ways to set up a consistent development environment with all necessary dependencies. 758 | 759 | ### Using VS Code Dev Containers (Recommended) 760 | 761 | The easiest way to get started is using VS Code with the Dev Containers extension: 762 | 763 | 1. **Prerequisites:** 764 | - [VS Code](https://code.visualstudio.com/) 765 | - [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 766 | - [Docker Desktop](https://www.docker.com/products/docker-desktop) 767 | 768 | 2. **Setup:** 769 | ```bash 770 | git clone https://github.com/kenaniah/sidekiq-status.git 771 | cd sidekiq-status 772 | code . # Open in VS Code 773 | ``` 774 | 775 | 3. **Launch Container:** 776 | - When prompted, click "Reopen in Container" 777 | - Or use Command Palette (`Ctrl+Shift+P`): "Dev Containers: Reopen in Container" 778 | 779 | The devcontainer automatically provides: 780 | - **Ruby 3.4** with all required gems 781 | - **Redis 7.4.0** server (auto-started) 782 | - **VS Code extensions**: Ruby LSP, Endwise, Docker support 783 | - **Pre-configured environment** with proper PATH and aliases 784 | 785 | ### Manual Development Setup 786 | 787 | If you prefer a local setup: 788 | 789 | 1. **Install Dependencies:** 790 | ```bash 791 | # Ruby 3.2+ required 792 | ruby --version # Verify version 793 | 794 | # Install Redis (macOS) 795 | brew install redis 796 | brew services start redis 797 | 798 | # Install Redis (Ubuntu/Debian) 799 | sudo apt-get install redis-server 800 | sudo systemctl start redis-server 801 | ``` 802 | 803 | 2. **Clone and Setup:** 804 | ```bash 805 | git clone https://github.com/kenaniah/sidekiq-status.git 806 | cd sidekiq-status 807 | bundle install 808 | ``` 809 | 810 | ### Docker Compose Setup 811 | 812 | For a containerized development environment without VS Code: 813 | 814 | ```bash 815 | # Start development environment 816 | docker compose -f .devcontainer/docker-compose.yml up -d 817 | 818 | # Enter the container 819 | docker compose -f .devcontainer/docker-compose.yml exec app bash 820 | 821 | # Install dependencies 822 | bundle install 823 | 824 | # Stop environment 825 | docker compose -f .devcontainer/docker-compose.yml down 826 | ``` 827 | 828 | ## Testing with Appraisal 829 | 830 | This project uses [Appraisal](https://github.com/thoughtbot/appraisal) to ensure compatibility across multiple Sidekiq versions. This is crucial because Sidekiq has breaking changes between major versions. 831 | 832 | ### Supported Versions 833 | 834 | Current test matrix includes: 835 | - **Sidekiq 7.0.x** - Stable release 836 | - **Sidekiq 7.3.x** - Recent stable 837 | - **Sidekiq 7.x** - Latest 7.x 838 | - **Sidekiq 8.0.x** - Latest major version 839 | - **Sidekiq 8.x** - Bleeding edge 840 | 841 | ### Appraisal Workflow 842 | 843 | #### 1. Install All Dependencies 844 | 845 | ```bash 846 | # Install base dependencies 847 | bundle install 848 | 849 | # Generate and install appraisal gemfiles 850 | bundle exec appraisal install 851 | ``` 852 | 853 | This creates version-specific Gemfiles in `gemfiles/` directory: 854 | ``` 855 | gemfiles/ 856 | ├── sidekiq_7.0.gemfile # Sidekiq ~> 7.0.0 857 | ├── sidekiq_7.3.gemfile # Sidekiq ~> 7.3.0 858 | ├── sidekiq_7.x.gemfile # Sidekiq ~> 7 859 | ├── sidekiq_8.0.gemfile # Sidekiq ~> 8.0.0 860 | └── sidekiq_8.x.gemfile # Sidekiq ~> 8 861 | ``` 862 | 863 | #### 2. Running Tests 864 | 865 | **Test all Sidekiq versions:** 866 | ```bash 867 | bundle exec appraisal rake spec 868 | ``` 869 | 870 | **Test specific version:** 871 | ```bash 872 | # Test against Sidekiq 7.0.x 873 | bundle exec appraisal sidekiq-7.0 rake spec 874 | 875 | # Test against Sidekiq 7.3.x 876 | bundle exec appraisal sidekiq-7.3 rake spec 877 | 878 | # Test against Sidekiq 8.x 879 | bundle exec appraisal sidekiq-8.x rake spec 880 | ``` 881 | 882 | **Quick test with current Gemfile:** 883 | ```bash 884 | bundle exec rake spec 885 | # or 886 | rake spec 887 | ``` 888 | 889 | #### 3. Interactive Debugging 890 | 891 | **Start console with specific Sidekiq version:** 892 | ```bash 893 | # Debug with Sidekiq 7.0.x dependencies 894 | bundle exec appraisal sidekiq-7.0 irb 895 | ``` 896 | 897 | **Run individual test files:** 898 | ```bash 899 | # Test specific file with Sidekiq 8.x 900 | bundle exec appraisal sidekiq-8.x rspec spec/lib/sidekiq-status/worker_spec.rb 901 | 902 | # Run with verbose output 903 | bundle exec appraisal sidekiq-8.x rspec spec/lib/sidekiq-status/worker_spec.rb -v 904 | ``` 905 | 906 | #### 4. Updating Dependencies 907 | 908 | **Regenerate gemfiles after dependency changes:** 909 | ```bash 910 | # Update Appraisals file, then: 911 | bundle exec appraisal generate 912 | 913 | # Install new dependencies 914 | bundle exec appraisal install 915 | ``` 916 | 917 | **Update specific version:** 918 | ```bash 919 | # Update only Sidekiq 7.x dependencies 920 | bundle exec appraisal sidekiq-7.x bundle update 921 | ``` 922 | 923 | ### Testing Best Practices 924 | 925 | #### Running Tests in CI/CD Style 926 | 927 | ```bash 928 | # Full test suite (like GitHub Actions) 929 | bundle exec appraisal install 930 | bundle exec appraisal rake spec 931 | 932 | # Check for dependency issues 933 | bundle exec bundle-audit check --update 934 | ``` 935 | 936 | ### Common Development Tasks 937 | 938 | ```bash 939 | # Start Redis for testing 940 | redis-server 941 | 942 | # Run Sidekiq worker with test environment 943 | bundle exec sidekiq -r ./spec/environment.rb 944 | 945 | # Start IRB with sidekiq-status loaded 946 | bundle exec irb -r ./lib/sidekiq-status 947 | 948 | # Generate test coverage report 949 | COVERAGE=true bundle exec rake spec 950 | open coverage/index.html 951 | ``` 952 | 953 | ### Docker Development Shortcuts 954 | 955 | ```bash 956 | # Quick test run using Docker 957 | docker compose run --rm sidekiq-status bundle exec rake spec 958 | 959 | # Interactive shell in container 960 | docker compose run --rm sidekiq-status bash 961 | 962 | # Test specific Sidekiq version in Docker 963 | docker compose run --rm sidekiq-status bundle exec appraisal sidekiq-8.x rake spec 964 | ``` 965 | 966 | ## Contributing 967 | 968 | Bug reports and pull requests are welcome. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct. 969 | 970 | 1. Fork it 971 | 2. Create your feature branch (`git checkout -b my-new-feature`) 972 | 3. Commit your changes along with test cases (`git commit -am 'Add some feature'`) 973 | 4. If possible squash your commits to one commit if they all belong to same feature. 974 | 5. Push to the branch (`git push origin my-new-feature`) 975 | 6. Create new Pull Request. 976 | 977 | ## Thanks 978 | * Pramod Shinde 979 | * Kenaniah Cerny 980 | * Clay Allsopp 981 | * Andrew Korzhuev 982 | * Jon Moses 983 | * Wayne Hoover 984 | * Dylan Robinson 985 | * Dmitry Novotochinov 986 | * Mohammed Elalj 987 | * Ben Sharpe 988 | 989 | ## License 990 | MIT License, see LICENSE for more details. 991 | © 2012 - 2016 Evgeniy Tsvigun 992 | --------------------------------------------------------------------------------