├── .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 |

Job Status: <%= h safe_url_params("jid") %>

3 | 4 |
5 | Uh oh! That job can't be found. It may have expired already. 6 |
7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gemfiles/*.lock 2 | *.gem 3 | *.rbc 4 | .bundle 5 | .config 6 | .rvmrc 7 | .ruby-version 8 | .yardoc 9 | Gemfile.lock 10 | InstalledFiles 11 | _yardoc 12 | coverage 13 | doc/ 14 | lib/bundler/man 15 | pkg 16 | rdoc 17 | spec/reports 18 | test/tmp 19 | test/version_tmp 20 | tmp 21 | 22 | .idea/ 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Run the test suite with docker compose 2 | services: 3 | sidekiq-status: 4 | build: . 5 | environment: 6 | - REDIS_URL=redis://redis 7 | volumes: 8 | - .:/app 9 | working_dir: /app 10 | command: bundle exec appraisal rake 11 | depends_on: 12 | - redis 13 | 14 | redis: 15 | image: redis:7.4.0 16 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "sidekiq-7.0" do 2 | gem "sidekiq", "~> 7.0.0" 3 | end 4 | 5 | appraise "sidekiq-7.3" do 6 | gem "sidekiq", "~> 7.3.0" 7 | end 8 | 9 | appraise "sidekiq-7.x" do 10 | gem "sidekiq", "~> 7" 11 | end 12 | 13 | appraise "sidekiq-8.0" do 14 | gem "sidekiq", "~> 8.0.0" 15 | end 16 | 17 | appraise "sidekiq-8.x" do 18 | gem "sidekiq", "~> 8" 19 | end 20 | -------------------------------------------------------------------------------- /spec/lib/sidekiq-status/testing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Sidekiq::Status do 4 | let!(:job_id) { SecureRandom.hex(12) } 5 | describe '.status' do 6 | it 'bypasses redis with inlining enabled' do 7 | Process.fork { 8 | require 'sidekiq-status/testing/inline' 9 | expect(Sidekiq::Status.status(job_id)).to eq :complete 10 | } 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/sidekiq-status/redis_client_adapter.rb: -------------------------------------------------------------------------------- 1 | # adapter for redis-client client 2 | class Sidekiq::Status::RedisClientAdapter 3 | def initialize(client) 4 | @client = client 5 | end 6 | 7 | def schedule_batch(key, options) 8 | @client.zrange(key, options[:start], options[:end], :byscore, :limit, options[:offset], options[:limit]) 9 | end 10 | 11 | def method_missing(method, *args) 12 | @client.send(method, *args) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/sidekiq-status/testing/inline.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Status 3 | class << self 4 | def status(jid) 5 | :complete 6 | end 7 | end 8 | 9 | module Storage 10 | def store_status(id, status, expiration = nil, redis_pool=nil) 11 | 'ok' 12 | end 13 | 14 | def store_for_id(id, status_updates, expiration = nil, redis_pool=nil) 15 | 'ok' 16 | end 17 | end 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: .. 5 | dockerfile: .devcontainer/Dockerfile 6 | volumes: 7 | - ..:/workspace:cached 8 | working_dir: /workspace 9 | environment: 10 | - REDIS_URL=redis://redis:6379 11 | command: sleep infinity 12 | depends_on: 13 | - redis 14 | 15 | redis: 16 | image: redis:7.4.0 17 | restart: unless-stopped 18 | ports: 19 | - "6379:6379" 20 | -------------------------------------------------------------------------------- /spec/environment.rb: -------------------------------------------------------------------------------- 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 13 | -------------------------------------------------------------------------------- /lib/sidekiq-status/redis_adapter.rb: -------------------------------------------------------------------------------- 1 | # adapter for redis-rb client 2 | class Sidekiq::Status::RedisAdapter 3 | def initialize(client) 4 | @client = client 5 | end 6 | 7 | def scan(**options, &block) 8 | @client.scan_each(**options, &block) 9 | end 10 | 11 | def schedule_batch(key, options) 12 | @client.zrangebyscore key, options[:start], options[:end], limit: [options[:offset], options[:limit]] 13 | end 14 | 15 | def method_missing(method, *args) 16 | @client.send(method, *args) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | dependency_scanning: 2 | image: docker:stable 3 | variables: 4 | DOCKER_DRIVER: overlay2 5 | allow_failure: true 6 | services: 7 | - docker:stable-dind 8 | script: 9 | - export SP_VERSION=$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/') 10 | - docker run 11 | --env DEP_SCAN_DISABLE_REMOTE_CHECKS="${DEP_SCAN_DISABLE_REMOTE_CHECKS:-false}" 12 | --volume "$PWD:/code" 13 | --volume /var/run/docker.sock:/var/run/docker.sock 14 | "registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$SP_VERSION" /code 15 | artifacts: 16 | reports: 17 | dependency_scanning: gl-dependency-scanning-report.json 18 | -------------------------------------------------------------------------------- /web/assets/statuses.js: -------------------------------------------------------------------------------- 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 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Evgeniy Tsvigun 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /spec/lib/sidekiq-status/worker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Sidekiq::Status::Worker do 4 | 5 | let!(:job_id) { SecureRandom.hex(12) } 6 | 7 | describe ".perform_async" do 8 | it "generates and returns job id" do 9 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 10 | expect(StubJob.perform_async()).to eq(job_id) 11 | end 12 | end 13 | 14 | describe ".expiration" do 15 | subject { StubJob.new } 16 | 17 | it "allows to set/get expiration" do 18 | expect(subject.expiration).to be_nil 19 | subject.expiration = :val 20 | expect(subject.expiration).to eq(:val) 21 | end 22 | end 23 | 24 | describe ".at" do 25 | subject { StubJob.new } 26 | 27 | it "records when the worker has started" do 28 | expect { subject.at(0) }.to(change { subject.retrieve('updated_at') }) 29 | end 30 | 31 | context "when setting the total for the worker" do 32 | it "records when the worker has started" do 33 | expect { subject.total(100) }.to(change { subject.retrieve('updated_at') }) 34 | end 35 | end 36 | 37 | it "records when the worker last worked" do 38 | expect { subject.at(0) }.to(change { subject.retrieve('updated_at') }) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Ruby CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | services: 17 | redis: 18 | image: redis 19 | options: >- 20 | --health-cmd "redis-cli ping" 21 | --health-interval 10s 22 | --health-timeout 5s 23 | --health-retries 5 24 | ports: 25 | - 6379:6379 26 | 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | ruby-version: 31 | - '3.2' 32 | - '3.3' 33 | - '3.4' 34 | - 'head' 35 | gemfile: 36 | - 'gemfiles/sidekiq_7.0.gemfile' 37 | - 'gemfiles/sidekiq_7.3.gemfile' 38 | - 'gemfiles/sidekiq_7.x.gemfile' 39 | - 'gemfiles/sidekiq_8.0.gemfile' 40 | - 'gemfiles/sidekiq_8.x.gemfile' 41 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} 42 | 43 | env: 44 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 45 | 46 | steps: 47 | - uses: actions/checkout@v5 48 | - name: Set up Ruby ${{ matrix.ruby-version }} 49 | uses: ruby/setup-ruby@v1 50 | with: 51 | ruby-version: ${{ matrix.ruby-version }} 52 | bundler-cache: true 53 | rubygems: latest 54 | - name: Run tests 55 | run: | 56 | bundle exec rake test 57 | -------------------------------------------------------------------------------- /sidekiq-status.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/sidekiq-status/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ['Evgeniy Tsvigun', 'Kenaniah Cerny'] 6 | gem.email = ['utgarda@gmail.com', 'kenaniah@gmail.com'] 7 | gem.summary = 'An extension to the sidekiq message processing to track your jobs' 8 | gem.homepage = 'https://github.com/kenaniah/sidekiq-status' 9 | gem.license = 'MIT' 10 | 11 | gem.files = `git ls-files`.split($\) 12 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 13 | gem.name = 'sidekiq-status' 14 | gem.require_paths = ['lib'] 15 | gem.required_ruby_version = '>= 3.2' 16 | gem.version = Sidekiq::Status::VERSION 17 | 18 | gem.add_dependency 'sidekiq', '>= 7', '< 9' 19 | gem.add_dependency 'chronic_duration' 20 | gem.add_dependency 'logger' 21 | gem.add_dependency 'base64' 22 | gem.add_development_dependency 'appraisal' 23 | gem.add_development_dependency 'colorize' 24 | gem.add_development_dependency 'irb' 25 | gem.add_development_dependency 'rack-test' 26 | gem.add_development_dependency 'rake' 27 | gem.add_development_dependency 'rspec' 28 | gem.add_development_dependency 'sinatra' 29 | gem.add_development_dependency 'webrick' 30 | gem.add_development_dependency 'rack-session' 31 | end 32 | -------------------------------------------------------------------------------- /lib/sidekiq-status/worker.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq::Status::Worker 2 | include Sidekiq::Status::Storage 3 | 4 | class Stopped < StandardError 5 | end 6 | 7 | attr_accessor :expiration 8 | 9 | # Stores multiple values into a job's status hash, 10 | # sets last update time 11 | # @param [Hash] status_updates updated values 12 | # @return [String] Redis operation status code 13 | def store(hash) 14 | store_for_id @provider_job_id || @job_id || @jid || "", hash, @expiration 15 | end 16 | 17 | # Read value from job status hash 18 | # @param String|Symbol hash key 19 | # @return [String] 20 | def retrieve(name) 21 | read_field_for_id @provider_job_id || @job_id || @jid || "", name 22 | end 23 | 24 | # Sets current task progress. This will stop the job if `.stop!` has been 25 | # called with this job's ID. 26 | # (inspired by resque-status) 27 | # @param Fixnum number of tasks done 28 | # @param String optional message 29 | # @return [String] 30 | def at(num, message = nil) 31 | @_status_total = 100 if @_status_total.nil? 32 | pct_complete = ((num / @_status_total.to_f) * 100).to_i rescue 0 33 | store(at: num, total: @_status_total, pct_complete: pct_complete, message: message) 34 | raise Stopped if retrieve(:stop) == 'true' 35 | end 36 | 37 | # Sets total number of tasks 38 | # @param Fixnum total number of tasks 39 | # @return [String] 40 | def total(num) 41 | @_status_total = num 42 | store(total: num) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sidekiq Status Development", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/workspace", 6 | 7 | "features": { 8 | "ghcr.io/devcontainers/features/git:1": {}, 9 | "ghcr.io/devcontainers/features/github-cli:1": {} 10 | }, 11 | 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "Shopify.ruby-lsp", 16 | "kaiwood.endwise", 17 | "ms-vscode.vscode-json", 18 | "redhat.vscode-yaml", 19 | "ms-azuretools.vscode-docker", 20 | "formulahendry.auto-rename-tag", 21 | "streetsidesoftware.code-spell-checker" 22 | ], 23 | "settings": { 24 | "ruby.useLanguageServer": true, 25 | "ruby.lint": { 26 | "rubocop": { 27 | "useBundler": true 28 | } 29 | }, 30 | "ruby.format": "rubocop", 31 | "files.associations": { 32 | "*.rb": "ruby", 33 | "Gemfile": "ruby", 34 | "Rakefile": "ruby", 35 | "*.gemspec": "ruby" 36 | }, 37 | "emmet.includeLanguages": { 38 | "erb": "html" 39 | } 40 | } 41 | } 42 | }, 43 | 44 | "forwardPorts": [6379], 45 | "portsAttributes": { 46 | "6379": { 47 | "label": "Redis", 48 | "onAutoForward": "ignore" 49 | } 50 | }, 51 | 52 | "postCreateCommand": "git config --global --add safe.directory /workspace && bundle install", 53 | 54 | "remoteUser": "vscode" 55 | } 56 | -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | # Sidekiq Status Devcontainer 2 | 3 | This devcontainer provides a complete development environment for the sidekiq-status gem. 4 | 5 | ## What's Included 6 | 7 | - **Ruby 3.4**: The same Ruby version used in the project's Dockerfile 8 | - **Redis 7.4.0**: Required for Sidekiq job processing and testing 9 | - **VS Code Extensions**: 10 | - Ruby LSP for language support 11 | - Endwise for Ruby code completion 12 | - Docker support 13 | - YAML and JSON support 14 | - Code spell checker 15 | 16 | ## Getting Started 17 | 18 | 1. Make sure you have VS Code with the Dev Containers extension installed 19 | 2. Open this project in VS Code 20 | 3. When prompted, click "Reopen in Container" or use the command palette: `Dev Containers: Reopen in Container` 21 | 4. The container will build and install all dependencies automatically 22 | 23 | ## Development Workflow 24 | 25 | Once the container is running: 26 | 27 | ```bash 28 | # Run tests 29 | bundle exec rake 30 | 31 | # Run specific tests 32 | bundle exec rspec spec/lib/sidekiq-status_spec.rb 33 | 34 | # Start an interactive Ruby session 35 | bundle exec irb 36 | 37 | # Run tests with different Sidekiq versions (using Appraisal) 38 | bundle exec appraisal sidekiq-6.x rspec 39 | bundle exec appraisal sidekiq-7.x rspec 40 | ``` 41 | 42 | ## Services 43 | 44 | - **Redis**: Available on port 6379 (forwarded to host) 45 | - **Application**: Ruby environment with all gems installed 46 | 47 | ## Environment Variables 48 | 49 | - `REDIS_URL`: Automatically set to `redis://redis:6379` for testing 50 | 51 | ## Debugging 52 | 53 | The devcontainer includes debugging support. You can set breakpoints in VS Code and use the Ruby debugger. 54 | 55 | ## Customization 56 | 57 | You can modify the devcontainer configuration in `.devcontainer/` to add additional tools or change settings as needed. 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | **Version 4.0.0** 2 | - Adds support for Ruby 3.3 and 3.4 3 | - Adds support for Sidekiq 8.x 4 | - Drops support for Sidekiq 6.x 5 | - Drops support for Ruby versions that are now end-of-life (Ruby 2.7.x - Ruby 3.1.x) 6 | - **BREAKING CHANGE**: Introduces breaking changes in job timestamp storage in Redis 7 | - **BREAKING CHANGE**: Renames `#working_at` to `#updated_at` 8 | - Major UI improvements with enhanced progress bars and better web interface styling 9 | - Adds fallback routes for retry and delete buttons 10 | - Adds a devcontainer to simplify development 11 | - Improved elapsed time and ETA calculations 12 | 13 | **Version 3.0.3** 14 | - Fixes a Sidekiq warning about the deprecated `hmset` redis command (https://github.com/kenaniah/sidekiq-status/pull/37) 15 | 16 | **Version 3.0.2** 17 | - Avoids setting statuses for non-status jobs when an exception is thrown (https://github.com/kenaniah/sidekiq-status/pull/32) 18 | 19 | **Version 3.0.1** 20 | - Adds elapsed time and ETA to the job status page (https://github.com/kenaniah/sidekiq-status/pull/13) 21 | 22 | **Version 3.0.0** 23 | - Drops support for Sidekiq 5.x 24 | - Adds support for Sidekiq 7.x 25 | - Migrates from Travis CI to GitHub Actions 26 | 27 | **Version 2.1.3** 28 | - Fixes redis deprecation warnings (https://github.com/kenaniah/sidekiq-status/issues/11) 29 | 30 | **Version 2.1.2** 31 | - Casts values to strings when HTML-encoding 32 | 33 | **Version 2.1.1** 34 | - Ensures parameter outputs are properly HTML-encoded 35 | 36 | **Version 2.1.0** 37 | - Adds support for Sidekiq 6.2.2+ (https://github.com/mperham/sidekiq/issues/4955) 38 | 39 | **Version 2.0.2** 40 | - Fixes for dark mode theme 41 | 42 | **Version 2.0.1** 43 | - Adds support for dark mode to the job status page 44 | 45 | **Version 2.0.0** 46 | - Adds support for Ruby 2.7, 3.0 47 | - Adds support for Sidekiq 6.x 48 | - Removes support for Ruby 2.3, 2.4, 2.5 49 | - Removes support for Sidekiq 3.x, 4.x 50 | 51 | **Versions 1.1.4 and prior** 52 | 53 | See https://github.com/utgarda/sidekiq-status/blob/master/CHANGELOG.md. 54 | -------------------------------------------------------------------------------- /spec/lib/sidekiq-status/client_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Sidekiq::Status::ClientMiddleware do 4 | 5 | let!(:redis) { Sidekiq.redis { |conn| conn } } 6 | let!(:job_id) { SecureRandom.hex(12) } 7 | 8 | before do 9 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 10 | end 11 | 12 | describe "without :expiration parameter" do 13 | 14 | it "sets queued status" do 15 | expect(StubJob.perform_async 'arg1' => 'val1').to eq(job_id) 16 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('queued') 17 | expect(Sidekiq::Status::queued?(job_id)).to be_truthy 18 | end 19 | 20 | it "sets status hash ttl" do 21 | expect(StubJob.perform_async 'arg1' => 'val1').to eq(job_id) 22 | expect(1..Sidekiq::Status::DEFAULT_EXPIRY).to cover redis.ttl("sidekiq:status:#{job_id}") 23 | end 24 | 25 | context "when redis_pool passed" do 26 | it "uses redis_pool" do 27 | redis_pool = double(:redis_pool) 28 | allow(redis_pool).to receive(:with) 29 | expect(Sidekiq).to_not receive(:redis) 30 | Sidekiq::Status::ClientMiddleware.new.call(StubJob, {'jid' => SecureRandom.hex}, :queued, redis_pool) do end 31 | end 32 | end 33 | 34 | context "when redis_pool is not passed" do 35 | it "uses Sidekiq.redis" do 36 | allow(Sidekiq).to receive(:redis) 37 | Sidekiq::Status::ClientMiddleware.new.call(StubJob, {'jid' => SecureRandom.hex}, :queued) do end 38 | end 39 | end 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 49 | end 50 | 51 | describe "with :expiration parameter" do 52 | 53 | let(:huge_expiration) { Sidekiq::Status::DEFAULT_EXPIRY * 100 } 54 | 55 | # Ensure client middleware is loaded with an expiration parameter set 56 | before do 57 | client_middleware expiration: huge_expiration 58 | end 59 | 60 | it "overwrites default expiry value" do 61 | StubJob.perform_async 'arg1' => 'val1' 62 | expect((Sidekiq::Status::DEFAULT_EXPIRY+1)..huge_expiration).to cover redis.ttl("sidekiq:status:#{job_id}") 63 | end 64 | 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /web/assets/statuses.css: -------------------------------------------------------------------------------- 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 | } 125 | -------------------------------------------------------------------------------- /lib/sidekiq-status/client_middleware.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/api' 2 | JOB_CLASS = Sidekiq.constants.include?(:JobRecord) ? Sidekiq::JobRecord : Sidekiq::Job 3 | 4 | module Sidekiq::Status 5 | # Should be in the client middleware chain 6 | class ClientMiddleware 7 | include Storage 8 | 9 | # Parameterized initialization, use it when adding middleware to client chain 10 | # chain.add Sidekiq::Status::ClientMiddleware, :expiration => 60 * 5 11 | # @param [Hash] opts middleware initialization options 12 | # @option opts [Fixnum] :expiration ttl for complete jobs 13 | def initialize(opts = {}) 14 | @expiration = opts[:expiration] 15 | end 16 | 17 | # Uses msg['jid'] id and puts :queued status in the job's Redis hash 18 | # @param [Class] worker_class if includes Sidekiq::Status::Worker, the job gets processed with the plugin 19 | # @param [Array] msg job arguments 20 | # @param [String] queue the queue's name 21 | # @param [ConnectionPool] redis_pool optional redis connection pool 22 | def call(worker_class, msg, queue, redis_pool=nil) 23 | 24 | # Determine the actual job class 25 | klass = (!msg["args"][0].is_a?(String) && msg["args"][0]["job_class"]) || worker_class rescue worker_class 26 | job_class = if klass.is_a?(Class) 27 | klass 28 | elsif Module.const_defined?(klass) 29 | Module.const_get(klass) 30 | else 31 | nil 32 | end 33 | 34 | # Store data if the job is a Sidekiq::Status::Worker 35 | if job_class && job_class.ancestors.include?(Sidekiq::Status::Worker) 36 | initial_metadata = { 37 | jid: msg['jid'], 38 | status: :queued, 39 | worker: JOB_CLASS.new(msg, queue).display_class, 40 | args: display_args(msg, queue), 41 | enqueued_at: Time.now.to_i 42 | } 43 | store_for_id msg['jid'], initial_metadata, job_class.new.expiration || @expiration, redis_pool 44 | end 45 | 46 | yield 47 | 48 | end 49 | 50 | def display_args(msg, queue) 51 | job = JOB_CLASS.new(msg, queue) 52 | return job.display_args.to_a.empty? ? "{}" : job.display_args.to_json 53 | rescue Exception => e 54 | # For Sidekiq ~> 2.7 55 | return msg['args'].to_a.empty? ? nil : msg['args'].to_json 56 | end 57 | end 58 | 59 | # Helper method to easily configure sidekiq-status client middleware 60 | # whatever the Sidekiq version is. 61 | # @param [Sidekiq::Config] sidekiq_config the Sidekiq config 62 | # @param [Hash] client_middleware_options client middleware initialization options 63 | # @option client_middleware_options [Fixnum] :expiration ttl for complete jobs 64 | def self.configure_client_middleware(sidekiq_config, client_middleware_options = {}) 65 | sidekiq_config.client_middleware do |chain| 66 | chain.add Sidekiq::Status::ClientMiddleware, client_middleware_options 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/sidekiq-status/helpers.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq::Status 2 | module Web 3 | module Helpers 4 | COMMON_STATUS_HASH_KEYS = %w(enqueued_at started_at updated_at ended_at jid status worker args label pct_complete total at message elapsed eta) 5 | 6 | def safe_url_params(key) 7 | return url_params(key) if Sidekiq.major_version >= 8 8 | request.params[key.to_s] 9 | end 10 | 11 | def safe_route_params(key) 12 | return route_params(key) if Sidekiq.major_version >= 8 13 | env["rack.route_params"][key.to_sym] 14 | end 15 | 16 | def csrf_tag 17 | "" 18 | end 19 | 20 | def poll_path 21 | "?#{request.query_string}" if safe_url_params("poll") 22 | end 23 | 24 | def sidekiq_status_template(name) 25 | path = File.join(VIEW_PATH, name.to_s) + ".erb" 26 | File.open(path).read 27 | end 28 | 29 | def add_details_to_status(status) 30 | status['label'] = status_label(status['status']) 31 | status["pct_complete"] ||= pct_complete(status) 32 | status["elapsed"] ||= elapsed(status).to_s 33 | status["eta"] ||= eta(status).to_s 34 | status["custom"] = process_custom_data(status) 35 | return status 36 | end 37 | 38 | def process_custom_data(hash) 39 | hash.reject { |key, _| COMMON_STATUS_HASH_KEYS.include?(key) } 40 | end 41 | 42 | def pct_complete(status) 43 | return 100 if status['status'] == 'complete' 44 | Sidekiq::Status::pct_complete(status['jid']) || 0 45 | end 46 | 47 | def elapsed(status) 48 | started = Sidekiq::Status.started_at(status['jid']) 49 | return nil unless started 50 | case status['status'] 51 | when 'complete', 'failed', 'stopped', 'interrupted' 52 | ended = Sidekiq::Status.ended_at(status['jid']) 53 | return nil unless ended 54 | ended - started 55 | when 'working', 'retrying' 56 | Time.now.to_i - started 57 | end 58 | end 59 | 60 | def eta(status) 61 | Sidekiq::Status.eta(status['jid']) if status['status'] == 'working' 62 | end 63 | 64 | def status_label(status) 65 | case status 66 | when 'complete' 67 | 'success' 68 | when 'working', 'retrying' 69 | 'warning' 70 | when 'queued' 71 | 'primary' 72 | else 73 | 'danger' 74 | end 75 | end 76 | 77 | def has_sort_by?(value) 78 | ["worker", "status", "updated_at", "pct_complete", "message", "args", "elapsed"].include?(value) 79 | end 80 | 81 | def retry_job_action 82 | job = Sidekiq::RetrySet.new.find_job(safe_url_params("jid")) 83 | job ||= Sidekiq::DeadSet.new.find_job(safe_url_params("jid")) 84 | job.retry if job 85 | throw :halt, [302, { "Location" => request.referer }, []] 86 | end 87 | 88 | def delete_job_action 89 | Sidekiq::Status.delete(safe_url_params("jid")) 90 | throw :halt, [302, { "Location" => request.referer }, []] 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/support/test_jobs.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq-status' 2 | 3 | class StubJob 4 | include Sidekiq::Worker 5 | include Sidekiq::Status::Worker 6 | 7 | sidekiq_options 'retry' => false 8 | 9 | def perform(*args) 10 | end 11 | end 12 | 13 | class StubNoStatusJob 14 | include Sidekiq::Worker 15 | 16 | sidekiq_options 'retry' => false 17 | 18 | def perform(*args) 19 | end 20 | end 21 | 22 | 23 | class ExpiryJob < StubJob 24 | def expiration 25 | 15 26 | end 27 | end 28 | 29 | class LongJob < StubJob 30 | def perform(*args) 31 | sleep args[0] || 0.25 32 | end 33 | end 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 | 46 | class DataJob < StubJob 47 | def perform 48 | sleep 0.1 49 | store({data: 'meow'}) 50 | retrieve(:data).should == 'meow' 51 | sleep 0.1 52 | end 53 | end 54 | 55 | class CustomDataJob < StubJob 56 | def perform 57 | store({mister_cat: 'meow'}) 58 | sleep 0.5 59 | end 60 | end 61 | 62 | class ProgressJob < StubJob 63 | def perform 64 | total 500 65 | at 100, 'howdy, partner?' 66 | sleep 0.1 67 | end 68 | end 69 | 70 | class ConfirmationJob < StubJob 71 | def perform(*args) 72 | Sidekiq.redis do |conn| 73 | conn.publish "job_messages_#{jid}", "while in #perform, status = #{conn.hget "sidekiq:status:#{jid}", :status}" 74 | end 75 | end 76 | end 77 | 78 | class NoStatusConfirmationJob 79 | include Sidekiq::Worker 80 | def perform(id) 81 | Sidekiq.redis do |conn| 82 | conn.set "NoStatusConfirmationJob_#{id}", "done" 83 | end 84 | end 85 | end 86 | 87 | class FailingJob < StubJob 88 | def perform 89 | raise StandardError 90 | end 91 | end 92 | 93 | class FailingNoStatusJob < StubNoStatusJob 94 | def perform 95 | raise StandardError 96 | end 97 | end 98 | 99 | class RetryAndFailJob < StubJob 100 | sidekiq_options retry: 1 101 | 102 | def perform 103 | raise StandardError 104 | end 105 | end 106 | 107 | class FailingHardJob < StubJob 108 | def perform 109 | raise Exception 110 | end 111 | end 112 | 113 | class FailingHardNoStatusJob < StubNoStatusJob 114 | def perform 115 | raise Exception 116 | end 117 | end 118 | 119 | class ExitedJob < StubJob 120 | def perform 121 | raise SystemExit 122 | end 123 | end 124 | 125 | class ExitedNoStatusJob < StubNoStatusJob 126 | def perform 127 | raise SystemExit 128 | end 129 | end 130 | 131 | class InterruptedJob < StubJob 132 | def perform 133 | raise Interrupt 134 | end 135 | end 136 | 137 | class InterruptedNoStatusJob < StubNoStatusJob 138 | def perform 139 | raise Interrupt 140 | end 141 | end 142 | 143 | class RetriedJob < StubJob 144 | 145 | sidekiq_options retry: true 146 | sidekiq_retry_in do |count| 3 end # 3 second delay > job timeout in test suite 147 | 148 | def perform 149 | Sidekiq.redis do |conn| 150 | key = "RetriedJob_#{jid}" 151 | if [0, false].include? conn.exists(key) 152 | conn.set key, 'tried' 153 | raise StandardError 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/sidekiq-status.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq-status/version' 2 | require 'sidekiq-status/sidekiq_extensions' 3 | require 'sidekiq-status/storage' 4 | require 'sidekiq-status/worker' 5 | require 'sidekiq-status/redis_client_adapter' 6 | require 'sidekiq-status/redis_adapter' 7 | require 'sidekiq-status/client_middleware' 8 | require 'sidekiq-status/server_middleware' 9 | require 'sidekiq-status/web' if defined?(Sidekiq::Web) 10 | require 'chronic_duration' 11 | 12 | module Sidekiq::Status 13 | extend Storage 14 | DEFAULT_EXPIRY = 60 * 30 15 | STATUS = [ :queued, :working, :retrying, :complete, :stopped, :failed, :interrupted ].freeze 16 | 17 | class << self 18 | # Job status by id 19 | # @param [String] id job id returned by async_perform 20 | # @return [String] job status, possible values are in STATUS 21 | def get(job_id, field) 22 | read_field_for_id(job_id, field) 23 | end 24 | 25 | # Get all status fields for a job 26 | # @params [String] id job id returned by async_perform 27 | # @return [Hash] hash of all fields stored for the job 28 | def get_all(job_id) 29 | read_hash_for_id(job_id) 30 | end 31 | 32 | def status(job_id) 33 | status = get(job_id, :status) 34 | status.to_sym unless status.nil? 35 | end 36 | 37 | def cancel(job_id, job_unix_time = nil) 38 | delete_and_unschedule(job_id, job_unix_time) 39 | end 40 | 41 | def delete(job_id) 42 | delete_status(job_id) 43 | end 44 | 45 | def stop!(job_id) 46 | store_for_id(job_id, {stop: 'true'}) 47 | end 48 | 49 | alias_method :unschedule, :cancel 50 | 51 | STATUS.each do |name| 52 | define_method("#{name}?") do |job_id| 53 | status(job_id) == name 54 | end 55 | end 56 | 57 | # Methods for retrieving job completion 58 | def at(job_id) 59 | get(job_id, :at).to_i 60 | end 61 | 62 | def total(job_id) 63 | get(job_id, :total).to_i 64 | end 65 | 66 | def pct_complete(job_id) 67 | get(job_id, :pct_complete).to_i 68 | end 69 | 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 81 | end 82 | 83 | def ended_at(job_id) 84 | get(job_id, :ended_at)&.to_i 85 | end 86 | 87 | def eta(job_id) 88 | at = at(job_id) 89 | return nil if at.zero? 90 | 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) 95 | end 96 | 97 | def message(job_id) 98 | get(job_id, :message) 99 | end 100 | 101 | def wrap_redis_connection(conn) 102 | if Sidekiq.major_version >= 7 103 | conn.is_a?(RedisClientAdapter) ? conn : RedisClientAdapter.new(conn) 104 | else 105 | conn.is_a?(RedisAdapter) ? conn : RedisAdapter.new(conn) 106 | end 107 | end 108 | 109 | def redis_adapter 110 | Sidekiq.redis { |conn| yield wrap_redis_connection(conn) } 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/sidekiq-status/server_middleware.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/job_retry' 2 | 3 | module Sidekiq::Status 4 | # Should be in the server middleware chain 5 | class ServerMiddleware 6 | include Storage 7 | 8 | # Parameterized initialization, use it when adding middleware to server chain 9 | # chain.add Sidekiq::Status::ServerMiddleware, :expiration => 60 * 5 10 | # @param [Hash] opts middleware initialization options 11 | # @option opts [Fixnum] :expiration ttl for complete jobs 12 | def initialize(opts = {}) 13 | @expiration = opts[:expiration] 14 | end 15 | 16 | # Uses sidekiq's internal jid as id 17 | # puts :working status into Redis hash 18 | # initializes worker instance with id 19 | # 20 | # Exception handler sets :failed status, re-inserts worker and re-throws the exception 21 | # Worker::Stopped exception type are processed separately - :stopped status is set, no re-throwing 22 | # 23 | # @param [Worker] worker worker instance, processed here if its class includes Status::Worker 24 | # @param [Array] msg job args, should have jid format 25 | # @param [String] queue queue name 26 | def call(worker, msg, queue) 27 | 28 | # Initial assignment to prevent SystemExit & co. from excepting 29 | expiry = @expiration 30 | 31 | # Determine the actual job class 32 | klass = (!msg["args"][0].is_a?(String) && msg["args"][0]["job_class"]) || msg["class"] rescue msg["class"] 33 | job_class = klass.is_a?(Class) ? klass : Module.const_get(klass) 34 | 35 | # Bypass unless this is a Sidekiq::Status::Worker job 36 | unless job_class.ancestors.include?(Sidekiq::Status::Worker) 37 | yield 38 | return 39 | end 40 | 41 | begin 42 | # Determine job expiration 43 | expiry = job_class.new.expiration || @expiration rescue @expiration 44 | 45 | store_status worker.jid, :working, expiry 46 | yield 47 | store_status worker.jid, :complete, expiry 48 | rescue Worker::Stopped 49 | store_status worker.jid, :stopped, expiry 50 | rescue SystemExit, Interrupt 51 | store_status worker.jid, :interrupted, expiry 52 | raise 53 | rescue Exception 54 | status = :failed 55 | if msg['retry'] 56 | if retry_attempt_number(msg) < retry_attempts_from(msg['retry'], Sidekiq::JobRetry::DEFAULT_MAX_RETRY_ATTEMPTS) 57 | status = :retrying 58 | end 59 | end 60 | store_status(worker.jid, status, expiry) if job_class && job_class.ancestors.include?(Sidekiq::Status::Worker) 61 | raise 62 | end 63 | end 64 | 65 | private 66 | 67 | def retry_attempt_number(msg) 68 | if msg['retry_count'] 69 | msg['retry_count'] + 1 70 | else 71 | 0 72 | end 73 | end 74 | 75 | def retry_attempts_from(msg_retry, default) 76 | msg_retry.is_a?(Integer) ? msg_retry : default 77 | end 78 | end 79 | 80 | # Helper method to easily configure sidekiq-status server middleware 81 | # whatever the Sidekiq version is. 82 | # @param [Sidekiq::Config] sidekiq_config the Sidekiq config 83 | # @param [Hash] server_middleware_options server middleware initialization options 84 | # @option server_middleware_options [Fixnum] :expiration ttl for complete jobs 85 | def self.configure_server_middleware(sidekiq_config, server_middleware_options = {}) 86 | sidekiq_config.server_middleware do |chain| 87 | chain.add Sidekiq::Status::ServerMiddleware, server_middleware_options 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /web/views/statuses.erb: -------------------------------------------------------------------------------- 1 | <% require 'cgi'; def h(v); CGI.escape_html(v.to_s); end %> 2 | 3 | 4 |
5 |

Recent job statuses

6 | 26 |
27 | 28 | 29 | <% @headers.each do |hdr| %> 30 | 33 | <% end %> 34 | 37 | 38 | <% @statuses.each do |container| %> 39 | 40 | 43 | 46 | 49 | <% secs = Time.now.to_i - container["updated_at"].to_i %> 50 | 57 | 66 | 71 | 76 | 91 | 92 | <% end %> 93 | <% if @statuses.empty? %> 94 | 95 | 96 | 97 | <% end %> 98 |
31 | <%= h hdr[:name] %> 32 | 35 | Actions 36 |
41 |
"><%= h container["worker"] %>
42 |
44 |
<%= h container["args"] %>
45 |
47 | <%= h container["status"] %> 48 | "> 51 | <% if secs > 0 %> 52 | <%= ChronicDuration.output(secs, :weeks => true, :units => 2) %> ago 53 | <% else %> 54 | Now 55 | <% end %> 56 | 58 |
59 |
" aria-valuemin="0" aria-valuemax="100"> 60 |
61 | <%= container["pct_complete"].to_i %>% 62 |
63 |
64 |
65 |
67 | <% unless container["elapsed"].nil? %> 68 | <%= ChronicDuration.output(container["elapsed"].to_i, :weeks => true, :units => 2) || '0 secs' %> 69 | <% end %> 70 | 72 | <% if container["eta"] %> 73 | <%= ChronicDuration.output(container["eta"].to_i, :weeks => true, :units => 2) %> 74 | <% end %> 75 | 77 |
78 |
79 | " /> 80 | <%= csrf_tag %> 81 | <% if container["status"] == "complete" %> 82 | 83 | 84 | <% elsif container["status"] == "failed" %> 85 | 86 | 87 | <% end %> 88 |
89 |
90 |
99 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require 'colorize' 3 | require 'sidekiq' 4 | require 'sidekiq/processor' 5 | require 'sidekiq/manager' 6 | require 'sidekiq-status' 7 | 8 | # Clears jobs before every test 9 | RSpec.configure do |config| 10 | config.before(:each) do 11 | Sidekiq.redis { |conn| conn.flushall } 12 | client_middleware 13 | sleep 0.05 14 | end 15 | end 16 | 17 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 18 | 19 | # Configures client middleware 20 | def client_middleware client_middleware_options = {} 21 | Sidekiq.configure_client do |config| 22 | Sidekiq::Status.configure_client_middleware config, client_middleware_options 23 | end 24 | end 25 | 26 | def redis_thread messages_limit, *channels 27 | 28 | parent = Thread.current 29 | thread = Thread.new { 30 | messages = [] 31 | Sidekiq.redis do |conn| 32 | puts "Subscribing to #{channels} for #{messages_limit.to_s.bold} messages".cyan if ENV['DEBUG'] 33 | conn.subscribe_with_timeout 60, *channels do |on| 34 | on.subscribe do |ch, subscriptions| 35 | puts "Subscribed to #{ch}".cyan if ENV['DEBUG'] 36 | if subscriptions == channels.size 37 | sleep 0.1 while parent.status != "sleep" 38 | parent.run 39 | end 40 | end 41 | on.message do |ch, msg| 42 | puts "Message received: #{ch} -> #{msg}".white if ENV['DEBUG'] 43 | messages << msg 44 | conn.unsubscribe if messages.length >= messages_limit 45 | end 46 | end 47 | end 48 | puts "Returning from thread".cyan if ENV['DEBUG'] 49 | messages 50 | } 51 | 52 | Thread.stop 53 | yield if block_given? 54 | thread 55 | 56 | end 57 | 58 | def redis_client_thread message_limit, *channels 59 | thread = Thread.new { 60 | messages = [] 61 | Sidekiq.redis do |conn| 62 | puts "Subscribing to #{channels} for #{message_limit.to_s.bold} messages".cyan if ENV['DEBUG'] 63 | pubsub = conn.pubsub 64 | pubsub.call("SUBSCRIBE", *channels) 65 | 66 | timeouts = 0 67 | loop do 68 | type, ch, msg = pubsub.next_event(2) 69 | next if type == "subscribe" 70 | if msg 71 | puts "Message received: #{ch} -> #{msg}".white if ENV['DEBUG'] 72 | messages << msg 73 | break if messages.length >= message_limit 74 | else 75 | # no new message was received in the allocated timeout 76 | timeouts += 1 77 | break if timeouts >= 30 78 | end 79 | end 80 | end 81 | puts "Returning from thread".cyan if ENV['DEBUG'] 82 | messages 83 | } 84 | sleep 0.1 85 | yield if block_given? 86 | thread.join 87 | end 88 | 89 | def branched_redis_thread n, *channels, &block 90 | if Sidekiq.major_version < 7 91 | redis_thread(n, *channels, &block) 92 | else 93 | redis_client_thread(n, *channels, &block) 94 | end 95 | end 96 | 97 | def capture_status_updates n, &block 98 | branched_redis_thread(n, "status_updates", &block).value 99 | end 100 | 101 | # Configures server middleware and launches a sidekiq server 102 | def start_server server_middleware_options = {} 103 | 104 | # Creates a process for the Sidekiq server 105 | pid = Process.fork do 106 | 107 | # Redirect the server's outputs 108 | $stdout.reopen File::NULL, 'w' unless ENV['DEBUG'] 109 | $stderr.reopen File::NULL, 'w' unless ENV['DEBUG'] 110 | 111 | # Load and configure server options 112 | require 'sidekiq/cli' 113 | 114 | # Add the server middleware 115 | Sidekiq.configure_server do |config| 116 | config.concurrency = 5 117 | config.redis = Sidekiq::RedisConnection.create if Sidekiq.major_version < 7 118 | Sidekiq::Status.configure_server_middleware config, server_middleware_options 119 | end 120 | 121 | # Launch 122 | puts "Server starting".yellow if ENV['DEBUG'] 123 | instance = Sidekiq::CLI.instance 124 | instance.parse(['-r', File.expand_path('test_environment.rb', File.dirname(__FILE__))]) 125 | instance.run 126 | 127 | end 128 | 129 | # Run the client-side code 130 | yield 131 | 132 | # Pause to ensure all jobs are picked up & started before TERM is sent 133 | sleep 0.2 134 | 135 | # Attempt to shut down the server normally 136 | Process.kill 'TERM', pid 137 | Process.wait pid 138 | 139 | ensure 140 | 141 | # Ensure the server is actually dead 142 | Process.kill 'KILL', pid rescue "OK" # it's OK if the process is gone already 143 | 144 | end 145 | -------------------------------------------------------------------------------- /spec/lib/sidekiq-status/web_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'sidekiq-status/web' 3 | require 'rack/test' 4 | require 'base64' 5 | 6 | describe 'sidekiq status web' do 7 | include Rack::Test::Methods 8 | 9 | let!(:redis) { Sidekiq.redis { |conn| conn } } 10 | let!(:job_id) { SecureRandom.hex(12) } 11 | 12 | def app 13 | @app ||= Sidekiq::Web.new 14 | end 15 | 16 | before do 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 21 | end 22 | 23 | around { |example| start_server(&example) } 24 | 25 | it 'shows the list of jobs in progress' do 26 | capture_status_updates(2) do 27 | expect(LongJob.perform_async(0.5)).to eq(job_id) 28 | end 29 | 30 | get '/statuses' 31 | expect(last_response).to be_ok 32 | expect(last_response.body).to match(/#{job_id}/) 33 | expect(last_response.body).to match(/LongJob/) 34 | expect(last_response.body).to match(/working/) 35 | end 36 | 37 | it 'allows filtering the list of jobs by status' do 38 | capture_status_updates(2) do 39 | LongJob.perform_async(0.5) 40 | end 41 | 42 | get '/statuses?status=working' 43 | expect(last_response).to be_ok 44 | expect(last_response.body).to match(/#{job_id}/) 45 | expect(last_response.body).to match(/LongJob/) 46 | expect(last_response.body).to match(/working/) 47 | end 48 | 49 | it 'allows filtering the list of jobs by completed status' do 50 | capture_status_updates(2) do 51 | LongJob.perform_async(0.5) 52 | end 53 | get '/statuses?status=completed' 54 | expect(last_response).to be_ok 55 | expect(last_response.body).to_not match(/LongJob/) 56 | end 57 | 58 | it 'shows a single job in progress' do 59 | capture_status_updates(2) do 60 | LongJob.perform_async(1, 'another argument') 61 | end 62 | 63 | get "/statuses/#{job_id}" 64 | expect(last_response).to be_ok 65 | expect(last_response.body).to match(/#{job_id}/) 66 | expect(last_response.body).to match(/1,"another argument"/) 67 | expect(last_response.body).to match(/working/) 68 | end 69 | 70 | it 'shows custom data for a single job' do 71 | capture_status_updates(3) do 72 | CustomDataJob.perform_async 73 | end 74 | 75 | get "/statuses/#{job_id}" 76 | expect(last_response).to be_ok 77 | expect(last_response.body).to match(/mister_cat/) 78 | expect(last_response.body).to match(/meow/) 79 | end 80 | 81 | it 'show an error when the requested job ID is not found' do 82 | get '/statuses/12345' 83 | expect(last_response).to be_not_found 84 | expect(last_response.body).to match(/That job can't be found/) 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 153 | end 154 | -------------------------------------------------------------------------------- /web/views/status.erb: -------------------------------------------------------------------------------- 1 | <% require 'cgi'; def h(v); CGI.escape_html(v.to_s); end %> 2 | <% 3 | def format_iso_timestamp(timestamp) 4 | return "n/a" unless timestamp && !timestamp.empty? 5 | Time.at(timestamp.to_i).utc.iso8601 6 | end 7 | %> 8 | 9 | 10 |

11 | Job Status 12 | 13 | <%= h @status["status"] %> 14 | 15 |

16 | 17 |
18 |
" aria-valuemin="0" aria-valuemax="100"> 19 |
20 | <%= @status["pct_complete"].to_i %>% 21 |
22 |
23 |
24 | 25 |
26 | 27 |
28 |

Job Details

29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 69 | 70 | 71 | 72 | 78 | 79 | <% if @status["status"] == "working" %> 80 | 81 | 82 | 89 | 90 | <% end %> 91 | 92 | 93 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 124 | 125 | 126 | 127 | 128 | 129 | 130 |
Job ID<%= h @status["jid"] %>
Job<%= h @status["worker"] %>
Arguments 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 |
Status<%= h @status["status"] %>
Elapsed Time 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 |
Progress 73 | <%= @status["pct_complete"].to_i %>% 74 | <% if @status["at"] && @status["total"] %> 75 | (<%= @status["at"] %> of <%= @status["total"] %>) 76 | <% end %> 77 |
ETA 83 | <% if @status["eta"] %> 84 | <%= ChronicDuration.output(@status["eta"].to_i, :weeks => true, :units => 2) %> 85 | <% else %> 86 | n/a 87 | <% end %> 88 |
Message 94 | <% if @status["message"] && !@status["message"].empty? %> 95 | <%= h(@status["message"]) %> 96 | <% else %> 97 | none 98 | <% end %> 99 |
Enqueued At<%= format_iso_timestamp(@status["enqueued_at"]) %>
Started At<%= format_iso_timestamp(@status["started_at"]) %>
Last Updated 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 |
Ended At<%= format_iso_timestamp(@status["ended_at"]) %>
131 | 132 | <% if @status["custom"] && @status["custom"].any? %> 133 |
134 |

Custom Data

135 |
136 | 137 | <% @status["custom"].each do |key, val| %> 138 | 139 | 140 | 147 | 148 | <% end %> 149 |
<%= h key %> 141 | <% if val && val.to_s.include?("\n") %> 142 |
<%= h val %>
143 | <% else %> 144 | <%= h(val) || "none" %> 145 | <% end %> 146 |
150 | <% end %> 151 | 152 |
153 | 154 | 155 | ← back to all statuses 156 | 157 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | require "bundler/setup" 4 | require "bundler/gem_tasks" 5 | 6 | require 'rspec/core/rake_task' 7 | 8 | RSpec::Core::RakeTask.new(:spec) 9 | task :test => :spec 10 | 11 | task :default => :spec 12 | 13 | desc "Launch a minimal server with Sidekiq UI at /sidekiq" 14 | task :server do 15 | require 'webrick' 16 | require 'rack' 17 | require 'rack/session' 18 | require 'stringio' 19 | require 'sidekiq' 20 | require 'sidekiq/web' 21 | require 'sidekiq-status' 22 | 23 | # Create a Rack application 24 | app = Rack::Builder.new do 25 | # Add session middleware for Sidekiq::Web CSRF protection 26 | use Rack::Session::Cookie, 27 | secret: ENV['SESSION_SECRET'] || 'development_secret_key_that_is_definitely_long_enough_for_rack_session_cookie_middleware', 28 | same_site: true, 29 | max_age: 86400 30 | 31 | map "/sidekiq" do 32 | run Sidekiq::Web 33 | end 34 | 35 | map "/" do 36 | run lambda { |env| 37 | [ 38 | 200, 39 | { 'Content-Type' => 'text/html' }, 40 | [<<~HTML 41 | 42 | 43 | 44 | Sidekiq Status Server 45 | 46 | 47 |

Sidekiq Status Server

48 |

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 | [![Gem Version](https://badge.fury.io/rb/sidekiq-status.svg)](https://badge.fury.io/rb/sidekiq-status) 3 | [![Build Status](https://github.com/kenaniah/sidekiq-status/actions/workflows/ci.yaml/badge.svg)](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 | ![Sidekiq Status Web](web/sidekiq-status-web.png) 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 | ![Sidekiq Status Web](web/sidekiq-status-single-web.png) 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 | --------------------------------------------------------------------------------