├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .gitlab-ci.yml ├── .rspec ├── Appraisals ├── CHANGELOG.md ├── Dockerfile ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── docker-compose.yml ├── gemfiles ├── sidekiq_6.1.gemfile ├── sidekiq_6.x.gemfile └── sidekiq_7.x.gemfile ├── lib ├── sidekiq-status.rb └── sidekiq-status │ ├── client_middleware.rb │ ├── redis_adapter.rb │ ├── redis_client_adapter.rb │ ├── server_middleware.rb │ ├── sidekiq_extensions.rb │ ├── storage.rb │ ├── testing │ └── inline.rb │ ├── version.rb │ ├── web.rb │ └── worker.rb ├── sidekiq-status.gemspec ├── spec ├── environment.rb ├── lib │ ├── sidekiq-status │ │ ├── client_middleware_spec.rb │ │ ├── server_middleware_spec.rb │ │ ├── testing_spec.rb │ │ ├── web_spec.rb │ │ └── worker_spec.rb │ └── sidekiq-status_spec.rb ├── spec_helper.rb └── support │ └── test_jobs.rb └── web ├── sidekiq-status-single-web.png ├── sidekiq-status-web.png └── views ├── status.erb ├── status_not_found.erb └── statuses.erb /.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 | - '2.7' 32 | - '3.0' 33 | - '3.1' 34 | - '3.2' 35 | - '3.3' 36 | - '3.4' 37 | gemfile: 38 | - 'gemfiles/sidekiq_6.1.gemfile' 39 | - 'gemfiles/sidekiq_6.x.gemfile' 40 | - 'gemfiles/sidekiq_7.x.gemfile' 41 | 42 | env: 43 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 44 | 45 | steps: 46 | - uses: actions/checkout@v3 47 | - name: Set up Ruby ${{ matrix.ruby-version }} 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: ${{ matrix.ruby-version }} 51 | bundler-cache: true 52 | rubygems: latest 53 | - name: Run tests 54 | run: | 55 | bundle exec rake test 56 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | 4 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "sidekiq-5.x" do 2 | gem "sidekiq", "~> 5" 3 | end 4 | 5 | appraise "sidekiq-6.1" do 6 | gem "sidekiq", "~> 6.1" 7 | end 8 | 9 | appraise "sidekiq-6.x" do 10 | gem "sidekiq", "~> 6" 11 | end 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | **Version 3.0.3** 2 | - Fixes a Sidekiq warning about the deprecated `hmset` redis command (https://github.com/kenaniah/sidekiq-status/pull/37) 3 | 4 | **Version 3.0.2** 5 | - Avoids setting statuses for non-status jobs when an exception is thrown (https://github.com/kenaniah/sidekiq-status/pull/32) 6 | 7 | **Version 3.0.1** 8 | - Adds elapsed time and ETA to the job status page (https://github.com/kenaniah/sidekiq-status/pull/13) 9 | 10 | **Version 3.0.0** 11 | - Drops support for Sidekiq 5.x 12 | - Adds support for Sidekiq 7.x 13 | - Migrates from Travis CI to GitHub Actions 14 | 15 | **Version 2.1.3** 16 | - Fixes redis deprecation warnings (https://github.com/kenaniah/sidekiq-status/issues/11) 17 | 18 | **Version 2.1.2** 19 | - Casts values to strings when HTML-encoding 20 | 21 | **Version 2.1.1** 22 | - Ensures parameter outputs are properly HTML-encoded 23 | 24 | **Version 2.1.0** 25 | - Adds support for Sidekiq 6.2.2+ (https://github.com/mperham/sidekiq/issues/4955) 26 | 27 | **Version 2.0.2** 28 | - Fixes for dark mode theme 29 | 30 | **Version 2.0.1** 31 | - Adds support for dark mode to the job status page 32 | 33 | **Version 2.0.0** 34 | - Adds support for Ruby 2.7, 3.0 35 | - Adds support for Sidekiq 6.x 36 | - Removes support for Ruby 2.3, 2.4, 2.5 37 | - Removes support for Sidekiq 3.x, 4.x 38 | 39 | **Versions 1.1.4 and prior** 40 | 41 | See https://github.com/utgarda/sidekiq-status/blob/master/CHANGELOG.md. 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # A sample Gemfile 2 | source "https://rubygems.org" 3 | 4 | gemspec 5 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sidekiq::Status 2 | [](https://badge.fury.io/rb/sidekiq-status) 3 | [](https://github.com/kenaniah/sidekiq-status/actions/) 4 | 5 | Sidekiq-status is an extension to [Sidekiq](https://github.com/mperham/sidekiq) that tracks information about your Sidekiq and provides a UI to that purpose. It was inspired by [resque-status](https://github.com/quirkey/resque-status). 6 | 7 | Requires Ruby 2.6+ and Sidekiq 6.0+ or newer. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | ```ruby 14 | gem 'sidekiq-status' 15 | ``` 16 | 17 | Or install it yourself as: 18 | 19 | ```bash 20 | gem install sidekiq-status 21 | ``` 22 | 23 | ### Migrating to Version 3.x from 2.x 24 | 25 | 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.** 26 | 27 | #### Migrating to Version 2.x from 1.x 28 | 29 | 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.** 30 | 31 | 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/. 32 | 33 | ## Setup Checklist 34 | 35 | To get started: 36 | 37 | * [Configure](#configuration) the middleware 38 | * (Optionally) add the [web interface](#adding-the-web-interface) 39 | * (Optionally) enable support for [ActiveJob](#activejob-support) 40 | 41 | ### Configuration 42 | 43 | To use, add sidekiq-status to the middleware chains. See [Middleware usage](https://github.com/mperham/sidekiq/wiki/Middleware) 44 | on the Sidekiq wiki for more info. 45 | 46 | ``` ruby 47 | require 'sidekiq' 48 | require 'sidekiq-status' 49 | 50 | Sidekiq.configure_client do |config| 51 | # accepts :expiration (optional) 52 | Sidekiq::Status.configure_client_middleware config, expiration: 30.minutes.to_i 53 | end 54 | 55 | Sidekiq.configure_server do |config| 56 | # accepts :expiration (optional) 57 | Sidekiq::Status.configure_server_middleware config, expiration: 30.minutes.to_i 58 | 59 | # accepts :expiration (optional) 60 | Sidekiq::Status.configure_client_middleware config, expiration: 30.minutes.to_i 61 | end 62 | ``` 63 | 64 | Include the `Sidekiq::Status::Worker` module in your jobs if you want the additional functionality of tracking progress and storing / retrieving job data. 65 | 66 | ``` ruby 67 | class MyJob 68 | include Sidekiq::Worker 69 | include Sidekiq::Status::Worker # enables job status tracking 70 | 71 | def perform(*args) 72 | # your code goes here 73 | end 74 | end 75 | ``` 76 | 77 | Note: _only jobs that include `Sidekiq::Status::Worker`_ will have their statuses tracked. 78 | 79 | To overwrite expiration on a per-worker basis, write an expiration method like the one below: 80 | 81 | ``` ruby 82 | class MyJob 83 | include Sidekiq::Worker 84 | include Sidekiq::Status::Worker # enables job status tracking 85 | 86 | def expiration 87 | @expiration ||= 60 * 60 * 24 * 30 # 30 days 88 | end 89 | 90 | def perform(*args) 91 | # your code goes here 92 | end 93 | end 94 | ``` 95 | 96 | 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. 97 | 98 | ### Expiration Times 99 | 100 | 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. 101 | 102 | 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`. 103 | 104 | 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. 105 | 106 | It is advised that you set the expiration time greater than the amount of time required to complete the job. 107 | 108 | The default expiration time is 30 minutes. 109 | 110 | ### Retrieving Status 111 | 112 | You may query for job status any time up to expiration: 113 | 114 | ``` ruby 115 | job_id = MyJob.perform_async(*args) 116 | # :queued, :working, :complete, :failed or :interrupted, nil after expiry (30 minutes) 117 | status = Sidekiq::Status::status(job_id) 118 | Sidekiq::Status::queued? job_id 119 | Sidekiq::Status::working? job_id 120 | Sidekiq::Status::retrying? job_id 121 | Sidekiq::Status::complete? job_id 122 | Sidekiq::Status::failed? job_id 123 | Sidekiq::Status::interrupted? job_id 124 | 125 | ``` 126 | Important: If you try any of the above status method after the expiration time, the result will be `nil` or `false`. 127 | 128 | ### ActiveJob Support 129 | 130 | This gem also supports ActiveJob jobs. Their status will be tracked automatically. 131 | 132 | To also enable job progress tracking and data storage features, simply add the `Sidekiq::Status::Worker` module to your base class, like below: 133 | 134 | ```ruby 135 | # app/jobs/application_job.rb 136 | class ApplicationJob < ActiveJob::Base 137 | include Sidekiq::Status::Worker 138 | end 139 | 140 | # app/jobs/my_job.rb 141 | class MyJob < ApplicationJob 142 | def perform(*args) 143 | # your code goes here 144 | end 145 | end 146 | ``` 147 | 148 | ### Tracking Progress and Storing Data 149 | 150 | sidekiq-status comes with a feature that allows you to track the progress of a job, as well as store and retrieve any custom data related to a job. 151 | 152 | ``` ruby 153 | class MyJob 154 | include Sidekiq::Worker 155 | include Sidekiq::Status::Worker # Important! 156 | 157 | def perform(*args) 158 | # your code goes here 159 | 160 | # the common idiom to track progress of your task 161 | total 100 # by default 162 | at 5, "Almost done" # 5/100 = 5 % completion 163 | 164 | # a way to associate data with your job 165 | store vino: 'veritas' 166 | 167 | # a way of retrieving stored data 168 | # remember that retrieved data is always String|nil 169 | vino = retrieve :vino 170 | end 171 | end 172 | 173 | job_id = MyJob.perform_async(*args) 174 | data = Sidekiq::Status::get_all job_id 175 | data # => {status: 'complete', update_time: 1360006573, vino: 'veritas'} 176 | Sidekiq::Status::get job_id, :vino #=> 'veritas' 177 | Sidekiq::Status::at job_id #=> 5 178 | Sidekiq::Status::total job_id #=> 100 179 | Sidekiq::Status::message job_id #=> "Almost done" 180 | Sidekiq::Status::pct_complete job_id #=> 5 181 | Sidekiq::Status::working_at job_id #=> 2718 182 | Sidekiq::Status::update_time job_id #=> 2819 183 | ``` 184 | 185 | ### Stopping a running job 186 | 187 | You can ask a job to stop execution by calling `.stop!` with its job ID. The 188 | next time the job calls `.at` it will raise 189 | `Sidekiq::Status::Worker::Stopped`. It will not attempt to retry. 190 | 191 | ```ruby 192 | job_id = MyJob.perform_async 193 | Sidekiq::Status.stop! job_id #=> true 194 | Sidekiq::Status.status job_id #=> :stopped 195 | ``` 196 | 197 | Note this will not kill a running job that is stuck. The job must call `.at` 198 | for it to be stopped in this way. 199 | 200 | ### Unscheduling 201 | 202 | ```ruby 203 | scheduled_job_id = MyJob.perform_in 3600 204 | Sidekiq::Status.cancel scheduled_job_id #=> true 205 | # doesn't cancel running jobs, this is more like unscheduling, therefore an alias: 206 | Sidekiq::Status.unschedule scheduled_job_id #=> true 207 | 208 | # returns false if invalid or wrong scheduled_job_id is provided 209 | Sidekiq::Status.unschedule some_other_unschedule_job_id #=> false 210 | Sidekiq::Status.unschedule nil #=> false 211 | Sidekiq::Status.unschedule '' #=> false 212 | # Note: cancel and unschedule are alias methods. 213 | ``` 214 | 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. 215 | 216 | ### Deleting Job Status by Job ID 217 | 218 | 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. 219 | 220 | ```ruby 221 | # returns number of keys/jobs that were removed 222 | Sidekiq::Status.delete(job_id) #=> 1 223 | Sidekiq::Status.delete(bad_job_id) #=> 0 224 | ``` 225 | 226 | ### Sidekiq Web Integration 227 | 228 | This gem provides an extension to Sidekiq's web interface with an index at `/statuses`. 229 | 230 |  231 | 232 | Information for an individual job may be found at `/statuses/:job_id`. 233 | 234 |  235 | 236 | Note: _only jobs that include `Sidekiq::Status::Worker`_ will be reported in the web interface. 237 | 238 | #### Adding the Web Interface 239 | 240 | To use, setup the Sidekiq Web interface according to Sidekiq documentation and add the `Sidekiq::Status::Web` require: 241 | 242 | ``` ruby 243 | require 'sidekiq/web' 244 | require 'sidekiq-status/web' 245 | ``` 246 | 247 | ### Testing 248 | 249 | Drawing analogy from [sidekiq testing by inlining](https://github.com/mperham/sidekiq/wiki/Testing#testing-workers-inline), 250 | `sidekiq-status` allows to bypass redis and return a stubbed `:complete` status. 251 | Since inlining your sidekiq worker will run it in-process, any exception it throws will make your test fail. 252 | It will also run synchronously, so by the time you get to query the job status, the job will have been completed 253 | successfully. 254 | In other words, you'll get the `:complete` status only if the job didn't fail. 255 | 256 | Inlining example: 257 | 258 | You can run Sidekiq workers inline in your tests by requiring the `sidekiq/testing/inline` file in your `{test,spec}_helper.rb`: 259 | 260 | ```ruby 261 | require 'sidekiq/testing/inline' 262 | ``` 263 | 264 | To use `sidekiq-status` inlining, require it too in your `{test,spec}_helper.rb`: 265 | 266 | ```ruby 267 | require 'sidekiq-status/testing/inline' 268 | ``` 269 | 270 | ## Contributing 271 | 272 | 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. 273 | 274 | 1. Fork it 275 | 2. Create your feature branch (`git checkout -b my-new-feature`) 276 | 3. Commit your changes along with test cases (`git commit -am 'Add some feature'`) 277 | 4. If possible squash your commits to one commit if they all belong to same feature. 278 | 5. Push to the branch (`git push origin my-new-feature`) 279 | 6. Create new Pull Request. 280 | 281 | ### Running the Test Suite 282 | 283 | You can use `docker compose` to easily run the test suite: 284 | 285 | ``` 286 | docker compose run --rm sidekiq-status 287 | ``` 288 | 289 | ## Thanks 290 | * Pramod Shinde 291 | * Kenaniah Cerny 292 | * Clay Allsopp 293 | * Andrew Korzhuev 294 | * Jon Moses 295 | * Wayne Hoover 296 | * Dylan Robinson 297 | * Dmitry Novotochinov 298 | * Mohammed Elalj 299 | * Ben Sharpe 300 | 301 | ## License 302 | MIT License, see LICENSE for more details. 303 | © 2012 - 2016 Evgeniy Tsvigun 304 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 rake 11 | depends_on: 12 | - redis 13 | 14 | redis: 15 | image: redis:7.4.0 16 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 6.1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/sidekiq_6.x.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "sidekiq", "~> 6" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 working_at(job_id) 71 | (get(job_id, :working_at) || Time.now).to_i 72 | end 73 | 74 | def update_time(job_id) 75 | (get(job_id, :update_time) || Time.now).to_i 76 | end 77 | 78 | def eta(job_id) 79 | at = at(job_id) 80 | return nil if at.zero? 81 | 82 | (Time.now.to_i - working_at(job_id)).to_f / at * (total(job_id) - at) 83 | end 84 | 85 | def message(job_id) 86 | get(job_id, :message) 87 | end 88 | 89 | def wrap_redis_connection(conn) 90 | if Sidekiq.major_version >= 7 91 | conn.is_a?(RedisClientAdapter) ? conn : RedisClientAdapter.new(conn) 92 | else 93 | conn.is_a?(RedisAdapter) ? conn : RedisAdapter.new(conn) 94 | end 95 | end 96 | 97 | def redis_adapter 98 | Sidekiq.redis { |conn| yield wrap_redis_connection(conn) } 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /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]["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 | } 42 | store_for_id msg['jid'], initial_metadata, job_class.new.expiration || @expiration, redis_pool 43 | end 44 | 45 | yield 46 | 47 | end 48 | 49 | def display_args(msg, queue) 50 | job = JOB_CLASS.new(msg, queue) 51 | return job.display_args.to_a.empty? ? "{}" : job.display_args.to_json 52 | rescue Exception => e 53 | # For Sidekiq ~> 2.7 54 | return msg['args'].to_a.empty? ? nil : msg['args'].to_json 55 | end 56 | end 57 | 58 | # Helper method to easily configure sidekiq-status client middleware 59 | # whatever the Sidekiq version is. 60 | # @param [Sidekiq] sidekiq_config the Sidekiq config 61 | # @param [Hash] client_middleware_options client middleware initialization options 62 | # @option client_middleware_options [Fixnum] :expiration ttl for complete jobs 63 | def self.configure_client_middleware(sidekiq_config, client_middleware_options = {}) 64 | sidekiq_config.client_middleware do |chain| 65 | chain.add Sidekiq::Status::ClientMiddleware, client_middleware_options 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/server_middleware.rb: -------------------------------------------------------------------------------- 1 | if Sidekiq.major_version >= 5 2 | require 'sidekiq/job_retry' 3 | end 4 | 5 | module Sidekiq::Status 6 | # Should be in the server middleware chain 7 | class ServerMiddleware 8 | 9 | DEFAULT_MAX_RETRY_ATTEMPTS = Sidekiq.major_version >= 5 ? Sidekiq::JobRetry::DEFAULT_MAX_RETRY_ATTEMPTS : 25 10 | 11 | include Storage 12 | 13 | # Parameterized initialization, use it when adding middleware to server chain 14 | # chain.add Sidekiq::Status::ServerMiddleware, :expiration => 60 * 5 15 | # @param [Hash] opts middleware initialization options 16 | # @option opts [Fixnum] :expiration ttl for complete jobs 17 | def initialize(opts = {}) 18 | @expiration = opts[:expiration] 19 | end 20 | 21 | # Uses sidekiq's internal jid as id 22 | # puts :working status into Redis hash 23 | # initializes worker instance with id 24 | # 25 | # Exception handler sets :failed status, re-inserts worker and re-throws the exception 26 | # Worker::Stopped exception type are processed separately - :stopped status is set, no re-throwing 27 | # 28 | # @param [Worker] worker worker instance, processed here if its class includes Status::Worker 29 | # @param [Array] msg job args, should have jid format 30 | # @param [String] queue queue name 31 | def call(worker, msg, queue) 32 | 33 | # Initial assignment to prevent SystemExit & co. from excepting 34 | expiry = @expiration 35 | 36 | # Determine the actual job class 37 | klass = msg["args"][0]["job_class"] || msg["class"] rescue msg["class"] 38 | job_class = klass.is_a?(Class) ? klass : Module.const_get(klass) 39 | 40 | # Bypass unless this is a Sidekiq::Status::Worker job 41 | unless job_class.ancestors.include?(Sidekiq::Status::Worker) 42 | yield 43 | return 44 | end 45 | 46 | begin 47 | # Determine job expiration 48 | expiry = job_class.new.expiration || @expiration rescue @expiration 49 | 50 | store_status worker.jid, :working, expiry 51 | yield 52 | store_status worker.jid, :complete, expiry 53 | rescue Worker::Stopped 54 | store_status worker.jid, :stopped, expiry 55 | rescue SystemExit, Interrupt 56 | store_status worker.jid, :interrupted, expiry 57 | raise 58 | rescue Exception 59 | status = :failed 60 | if msg['retry'] 61 | if retry_attempt_number(msg) < retry_attempts_from(msg['retry'], DEFAULT_MAX_RETRY_ATTEMPTS) 62 | status = :retrying 63 | end 64 | end 65 | store_status(worker.jid, status, expiry) if job_class && job_class.ancestors.include?(Sidekiq::Status::Worker) 66 | raise 67 | end 68 | end 69 | 70 | private 71 | 72 | def retry_attempt_number(msg) 73 | if msg['retry_count'] 74 | msg['retry_count'] + sidekiq_version_dependent_retry_offset 75 | else 76 | 0 77 | end 78 | end 79 | 80 | def retry_attempts_from(msg_retry, default) 81 | msg_retry.is_a?(Integer) ? msg_retry : default 82 | end 83 | 84 | def sidekiq_version_dependent_retry_offset 85 | Sidekiq.major_version >= 4 ? 1 : 0 86 | end 87 | end 88 | 89 | # Helper method to easily configure sidekiq-status server middleware 90 | # whatever the Sidekiq version is. 91 | # @param [Sidekiq] sidekiq_config the Sidekiq config 92 | # @param [Hash] server_middleware_options server middleware initialization options 93 | # @option server_middleware_options [Fixnum] :expiration ttl for complete jobs 94 | def self.configure_server_middleware(sidekiq_config, server_middleware_options = {}) 95 | sidekiq_config.server_middleware do |chain| 96 | if Sidekiq.major_version < 5 97 | chain.insert_after Sidekiq::Middleware::Server::Logging, 98 | Sidekiq::Status::ServerMiddleware, server_middleware_options 99 | else 100 | chain.add Sidekiq::Status::ServerMiddleware, server_middleware_options 101 | end 102 | end 103 | 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/sidekiq-status/storage.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq::Status::Storage 2 | RESERVED_FIELDS=%w(status stop update_time).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), 'update_time', 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 | store_for_id id, {status: status}, expiration, redis_pool 34 | end 35 | 36 | # Unschedules the job and deletes the Status 37 | # @param [String] id job id 38 | # @param [Num] job_unix_time, unix timestamp for the scheduled job 39 | def delete_and_unschedule(job_id, job_unix_time = nil) 40 | Sidekiq::Status.redis_adapter do |conn| 41 | scan_options = {offset: 0, conn: conn, start: (job_unix_time || '-inf'), end: (job_unix_time || '+inf')} 42 | 43 | while not (jobs = schedule_batch(scan_options)).empty? 44 | match = scan_scheduled_jobs_for_jid jobs, job_id 45 | unless match.nil? 46 | conn.zrem "schedule", match 47 | conn.del key(job_id) 48 | return true # Done 49 | end 50 | scan_options[:offset] += BATCH_LIMIT 51 | end 52 | end 53 | false 54 | end 55 | 56 | # Deletes status hash info for given job id 57 | # @param[String] job id 58 | # @retrun [Integer] number of keys that were removed 59 | def delete_status(id) 60 | redis_connection do |conn| 61 | conn.del(key(id)) 62 | end 63 | end 64 | 65 | # Gets a single valued from job status hash 66 | # @param [String] id job id 67 | # @param [String] Symbol field fetched field name 68 | # @return [String] Redis operation status code 69 | def read_field_for_id(id, field) 70 | Sidekiq::Status.redis_adapter do |conn| 71 | conn.hget(key(id), field) 72 | end 73 | end 74 | 75 | # Gets the whole status hash from the job status 76 | # @param [String] id job id 77 | # @return [Hash] Hash stored in redis 78 | def read_hash_for_id(id) 79 | Sidekiq::Status.redis_adapter do |conn| 80 | conn.hgetall(key(id)) 81 | end 82 | end 83 | 84 | private 85 | 86 | # Gets the batch of scheduled jobs based on input options 87 | # Uses Redis zrangebyscore for log(n) search, if unix-time is provided 88 | # @param [Hash] options, options hash containing (REQUIRED) keys: 89 | # - conn: Redis connection 90 | # - start: start score (i.e. -inf or a unix timestamp) 91 | # - end: end score (i.e. +inf or a unix timestamp) 92 | # - offset: current progress through (all) jobs (e.g.: 100 if you want jobs from 100 to BATCH_LIMIT) 93 | def schedule_batch(options) 94 | Sidekiq::Status.wrap_redis_connection(options[:conn]).schedule_batch("schedule", options.merge(limit: BATCH_LIMIT)) 95 | end 96 | 97 | # Searches the jobs Array for the job_id 98 | # @param [Array] scheduled_jobs, results of Redis schedule key 99 | # @param [String] id job id 100 | def scan_scheduled_jobs_for_jid(scheduled_jobs, job_id) 101 | # A Little skecthy, I know, but the structure of these internal JSON 102 | # is predefined in such a way where this will not catch unintentional elements, 103 | # and this is notably faster than performing JSON.parse() for every listing: 104 | scheduled_jobs.select { |job_listing| job_listing.match(/\"jid\":\"#{job_id}\"/) }[0] 105 | end 106 | 107 | # Yields redis connection. Uses redis pool if available. 108 | # @param [ConnectionPool] redis_pool optional redis connection pool 109 | def redis_connection(redis_pool=nil) 110 | if redis_pool 111 | redis_pool.with do |conn| 112 | yield conn 113 | end 114 | else 115 | Sidekiq.redis do |conn| 116 | yield conn 117 | end 118 | end 119 | end 120 | 121 | def key(id) 122 | "sidekiq:status:#{id}" 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/sidekiq-status/version.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Status 3 | VERSION = '3.0.3' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/sidekiq-status/web.rb: -------------------------------------------------------------------------------- 1 | # adapted from https://github.com/cryo28/sidekiq_status 2 | 3 | module Sidekiq::Status 4 | # Hook into *Sidekiq::Web* Sinatra app which adds a new "/statuses" page 5 | module Web 6 | # Location of Sidekiq::Status::Web view templates 7 | VIEW_PATH = File.expand_path('../../../web/views', __FILE__) 8 | 9 | DEFAULT_PER_PAGE_OPTS = [25, 50, 100].freeze 10 | DEFAULT_PER_PAGE = 25 11 | COMMON_STATUS_HASH_KEYS = %w(update_time jid status worker args label pct_complete total at message working_at elapsed eta) 12 | 13 | class << self 14 | def per_page_opts= arr 15 | @per_page_opts = arr 16 | end 17 | def per_page_opts 18 | @per_page_opts || DEFAULT_PER_PAGE_OPTS 19 | end 20 | def default_per_page= val 21 | @default_per_page = val 22 | end 23 | def default_per_page 24 | @default_per_page || DEFAULT_PER_PAGE 25 | end 26 | end 27 | 28 | # @param [Sidekiq::Web] app 29 | def self.registered(app) 30 | 31 | # Allow method overrides to support RESTful deletes 32 | app.set :method_override, true 33 | 34 | app.helpers do 35 | def csrf_tag 36 | "" 37 | end 38 | 39 | def poll_path 40 | "?#{request.query_string}" if params[:poll] 41 | end 42 | 43 | def sidekiq_status_template(name) 44 | path = File.join(VIEW_PATH, name.to_s) + ".erb" 45 | File.open(path).read 46 | end 47 | 48 | def add_details_to_status(status) 49 | status['label'] = status_label(status['status']) 50 | status["pct_complete"] ||= pct_complete(status) 51 | status["elapsed"] ||= elapsed(status).to_s 52 | status["eta"] ||= eta(status).to_s 53 | status["custom"] = process_custom_data(status) 54 | return status 55 | end 56 | 57 | def process_custom_data(hash) 58 | hash.reject { |key, _| COMMON_STATUS_HASH_KEYS.include?(key) } 59 | end 60 | 61 | def pct_complete(status) 62 | return 100 if status['status'] == 'complete' 63 | Sidekiq::Status::pct_complete(status['jid']) || 0 64 | end 65 | 66 | def elapsed(status) 67 | case status['status'] 68 | when 'complete' 69 | Sidekiq::Status.update_time(status['jid']) - Sidekiq::Status.working_at(status['jid']) 70 | when 'working', 'retrying' 71 | Time.now.to_i - Sidekiq::Status.working_at(status['jid']) 72 | end 73 | end 74 | 75 | def eta(status) 76 | Sidekiq::Status.eta(status['jid']) if status['status'] == 'working' 77 | end 78 | 79 | def status_label(status) 80 | case status 81 | when 'complete' 82 | 'success' 83 | when 'working', 'retrying' 84 | 'warning' 85 | when 'queued' 86 | 'primary' 87 | else 88 | 'danger' 89 | end 90 | end 91 | 92 | def has_sort_by?(value) 93 | ["worker", "status", "update_time", "pct_complete", "message", "args"].include?(value) 94 | end 95 | end 96 | 97 | app.get '/statuses' do 98 | 99 | jids = Sidekiq::Status.redis_adapter do |conn| 100 | conn.scan(match: 'sidekiq:status:*', count: 100).map do |key| 101 | key.split(':').last 102 | end.uniq 103 | end 104 | @statuses = [] 105 | 106 | jids.each do |jid| 107 | status = Sidekiq::Status::get_all jid 108 | next if !status || status.count < 2 109 | status = add_details_to_status(status) 110 | @statuses << status 111 | end 112 | 113 | sort_by = has_sort_by?(params[:sort_by]) ? params[:sort_by] : "update_time" 114 | sort_dir = "asc" 115 | 116 | if params[:sort_dir] == "asc" 117 | @statuses = @statuses.sort { |x,y| (x[sort_by] <=> y[sort_by]) || -1 } 118 | else 119 | sort_dir = "desc" 120 | @statuses = @statuses.sort { |y,x| (x[sort_by] <=> y[sort_by]) || 1 } 121 | end 122 | 123 | if params[:status] && params[:status] != "all" 124 | @statuses = @statuses.select {|job_status| job_status["status"] == params[:status] } 125 | end 126 | 127 | # Sidekiq pagination 128 | @total_size = @statuses.count 129 | @count = params[:per_page] ? params[:per_page].to_i : Sidekiq::Status::Web.default_per_page 130 | @count = @total_size if params[:per_page] == 'all' 131 | @current_page = params[:page].to_i < 1 ? 1 : params[:page].to_i 132 | @statuses = @statuses.slice((@current_page - 1) * @count, @count) 133 | 134 | @headers = [ 135 | {id: "worker", name: "Worker / JID", class: nil, url: nil}, 136 | {id: "args", name: "Arguments", class: nil, url: nil}, 137 | {id: "status", name: "Status", class: nil, url: nil}, 138 | {id: "update_time", name: "Last Updated", class: nil, url: nil}, 139 | {id: "pct_complete", name: "Progress", class: nil, url: nil}, 140 | {id: "elapsed", name: "Time Elapsed", class: nil, url: nil}, 141 | {id: "eta", name: "ETA", class: nil, url: nil}, 142 | ] 143 | 144 | @headers.each do |h| 145 | h[:url] = "statuses?" + params.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("&") 146 | h[:class] = "sorted_#{sort_dir}" if sort_by == h[:id] 147 | end 148 | 149 | erb(sidekiq_status_template(:statuses)) 150 | end 151 | 152 | app.get '/statuses/:jid' do 153 | job = Sidekiq::Status::get_all params['jid'] 154 | 155 | if job.empty? 156 | throw :halt, [404, {"Content-Type" => "text/html"}, [erb(sidekiq_status_template(:status_not_found))]] 157 | else 158 | @status = add_details_to_status(job) 159 | erb(sidekiq_status_template(:status)) 160 | end 161 | end 162 | 163 | # Retries a failed job from the status list 164 | app.put '/statuses' do 165 | job = Sidekiq::RetrySet.new.find_job(params[:jid]) 166 | job ||= Sidekiq::DeadSet.new.find_job(params[:jid]) 167 | job.retry if job 168 | throw :halt, [302, { "Location" => request.referer }, []] 169 | end 170 | 171 | # Removes a completed job from the status list 172 | app.delete '/statuses' do 173 | Sidekiq::Status.delete(params[:jid]) 174 | throw :halt, [302, { "Location" => request.referer }, []] 175 | end 176 | end 177 | end 178 | end 179 | 180 | unless defined?(Sidekiq::Web) 181 | require 'delegate' # Needed for sidekiq 5.x 182 | require 'sidekiq/web' 183 | end 184 | 185 | Sidekiq::Web.register(Sidekiq::Status::Web) 186 | ["per_page", "sort_by", "sort_dir", "status"].each do |key| 187 | Sidekiq::WebHelpers::SAFE_QPARAMS.push(key) 188 | end 189 | if Sidekiq::Web.tabs.is_a?(Array) 190 | # For sidekiq < 2.5 191 | Sidekiq::Web.tabs << "statuses" 192 | else 193 | Sidekiq::Web.tabs["Statuses"] = "statuses" 194 | end 195 | -------------------------------------------------------------------------------- /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 hask 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, working_at: working_at) 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, working_at: working_at) 43 | end 44 | 45 | private 46 | 47 | def working_at 48 | @working_at ||= Time.now.to_i 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /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.version = Sidekiq::Status::VERSION 16 | 17 | gem.add_dependency 'sidekiq', '>= 6.0', '< 8' 18 | gem.add_dependency 'chronic_duration' 19 | gem.add_development_dependency 'appraisal' 20 | gem.add_development_dependency 'colorize' 21 | gem.add_development_dependency 'rack-test' 22 | gem.add_development_dependency 'rake' 23 | gem.add_development_dependency 'rspec' 24 | gem.add_development_dependency 'sinatra' 25 | end 26 | -------------------------------------------------------------------------------- /spec/environment.rb: -------------------------------------------------------------------------------- 1 | # This file has been intentionally left blank 2 | -------------------------------------------------------------------------------- /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 | end 42 | 43 | describe "with :expiration parameter" do 44 | 45 | let(:huge_expiration) { Sidekiq::Status::DEFAULT_EXPIRY * 100 } 46 | 47 | # Ensure client middleware is loaded with an expiration parameter set 48 | before do 49 | client_middleware expiration: huge_expiration 50 | end 51 | 52 | it "overwrites default expiry value" do 53 | StubJob.perform_async 'arg1' => 'val1' 54 | expect((Sidekiq::Status::DEFAULT_EXPIRY+1)..huge_expiration).to cover redis.ttl("sidekiq:status:#{job_id}") 55 | end 56 | 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /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 Sidekiq::Status::Worker is not included in the job" do 49 | it "should not set a failed status" do 50 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 51 | start_server do 52 | expect(FailingNoStatusJob.perform_async).to eq(job_id) 53 | end 54 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 55 | end 56 | 57 | it "should not set any status when Exception raised" do 58 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 59 | start_server do 60 | expect(FailingHardNoStatusJob.perform_async).to eq(job_id) 61 | end 62 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 63 | end 64 | 65 | it "should not set any status on system exit signal" do 66 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 67 | start_server do 68 | expect(ExitedNoStatusJob.perform_async).to eq(job_id) 69 | end 70 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 71 | end 72 | 73 | it "should not set any status on interrupt signal" do 74 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 75 | start_server do 76 | expect(InterruptedNoStatusJob.perform_async).to eq(job_id) 77 | end 78 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to be_nil 79 | end 80 | end 81 | 82 | context "sets interrupted status" do 83 | it "on system exit signal" do 84 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 85 | start_server do 86 | expect(capture_status_updates(3) { 87 | expect(ExitedJob.perform_async).to eq(job_id) 88 | }).to eq([job_id]*3) 89 | end 90 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('interrupted') 91 | expect(Sidekiq::Status::interrupted?(job_id)).to be_truthy 92 | end 93 | 94 | it "on interrupt signal" do 95 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 96 | start_server do 97 | expect(capture_status_updates(3) { 98 | expect(InterruptedJob.perform_async).to eq(job_id) 99 | }).to eq([job_id]*3) 100 | end 101 | expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('interrupted') 102 | expect(Sidekiq::Status::interrupted?(job_id)).to be_truthy 103 | end 104 | 105 | end 106 | 107 | it "sets status hash ttl" do 108 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 109 | start_server do 110 | expect(StubJob.perform_async 'arg1' => 'val1').to eq(job_id) 111 | end 112 | expect(1..Sidekiq::Status::DEFAULT_EXPIRY).to cover redis.ttl("sidekiq:status:#{job_id}") 113 | end 114 | end 115 | 116 | describe "with :expiration parameter" do 117 | let(:huge_expiration) { Sidekiq::Status::DEFAULT_EXPIRY * 100 } 118 | before do 119 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 120 | end 121 | 122 | it "overwrites default expiry value" do 123 | start_server(:expiration => huge_expiration) do 124 | StubJob.perform_async 'arg1' => 'val1' 125 | end 126 | expect((Sidekiq::Status::DEFAULT_EXPIRY-1)..huge_expiration).to cover redis.ttl("sidekiq:status:#{job_id}") 127 | end 128 | 129 | it "can be overwritten by worker expiration method" do 130 | overwritten_expiration = huge_expiration * 100 131 | allow_any_instance_of(StubJob).to receive(:expiration).and_return(overwritten_expiration) 132 | start_server(:expiration => huge_expiration) do 133 | StubJob.perform_async 'arg1' => 'val1' 134 | end 135 | expect((huge_expiration+1)..overwritten_expiration).to cover redis.ttl("sidekiq:status:#{job_id}") 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | Sidekiq::Web 14 | end 15 | 16 | before do 17 | env 'rack.session', csrf: Base64.urlsafe_encode64('token') 18 | client_middleware 19 | allow(SecureRandom).to receive(:hex).and_return(job_id) 20 | end 21 | 22 | around { |example| start_server(&example) } 23 | 24 | it 'shows the list of jobs in progress' do 25 | capture_status_updates(2) do 26 | expect(LongJob.perform_async(0.5)).to eq(job_id) 27 | end 28 | 29 | get '/statuses' 30 | expect(last_response).to be_ok 31 | expect(last_response.body).to match(/#{job_id}/) 32 | expect(last_response.body).to match(/LongJob/) 33 | expect(last_response.body).to match(/working/) 34 | end 35 | 36 | it 'allows filtering the list of jobs by status' do 37 | capture_status_updates(2) do 38 | LongJob.perform_async(0.5) 39 | end 40 | 41 | get '/statuses?status=working' 42 | expect(last_response).to be_ok 43 | expect(last_response.body).to match(/#{job_id}/) 44 | expect(last_response.body).to match(/LongJob/) 45 | expect(last_response.body).to match(/working/) 46 | end 47 | 48 | it 'allows filtering the list of jobs by completed status' do 49 | capture_status_updates(2) do 50 | LongJob.perform_async(0.5) 51 | end 52 | get '/statuses?status=completed' 53 | expect(last_response).to be_ok 54 | expect(last_response.body).to_not match(/LongJob/) 55 | end 56 | 57 | it 'shows a single job in progress' do 58 | capture_status_updates(2) do 59 | LongJob.perform_async(1, 'another argument') 60 | end 61 | 62 | get "/statuses/#{job_id}" 63 | expect(last_response).to be_ok 64 | expect(last_response.body).to match(/#{job_id}/) 65 | expect(last_response.body).to match(/1,"another argument"/) 66 | expect(last_response.body).to match(/working/) 67 | end 68 | 69 | it 'shows custom data for a single job' do 70 | capture_status_updates(3) do 71 | CustomDataJob.perform_async 72 | end 73 | 74 | get "/statuses/#{job_id}" 75 | expect(last_response).to be_ok 76 | expect(last_response.body).to match(/mister_cat/) 77 | expect(last_response.body).to match(/meow/) 78 | end 79 | 80 | it 'show an error when the requested job ID is not found' do 81 | get '/statuses/12345' 82 | expect(last_response).to be_not_found 83 | expect(last_response.body).to match(/That job can't be found/) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /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('working_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('working_at') }) 34 | end 35 | end 36 | 37 | it "records when the worker last worked" do 38 | expect { subject.at(0) }.to(change { subject.retrieve('update_time') }) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /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 'update_time' 76 | end 77 | expect(hash = Sidekiq::Status.get_all(job_id)).to include 'status' => 'complete' 78 | expect(hash).to include 'update_time' 79 | end 80 | end 81 | 82 | describe '.delete' do 83 | it 'deletes the status hash for given job id' do 84 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 85 | start_server do 86 | expect(capture_status_updates(2) { 87 | expect(LongJob.perform_async(0.5)).to eq(job_id) 88 | }).to eq([job_id]*2) 89 | end 90 | expect(Sidekiq::Status.delete(job_id)).to eq(1) 91 | end 92 | 93 | it 'should not raise error while deleting status hash if invalid job id' do 94 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 95 | expect(Sidekiq::Status.delete(job_id)).to eq(0) 96 | end 97 | end 98 | 99 | describe ".cancel" do 100 | it "cancels a job by id" do 101 | allow(SecureRandom).to receive(:hex).twice.and_return(job_id, job_id_1) 102 | start_server do 103 | job = LongJob.perform_in(3600) 104 | expect(job).to eq(job_id) 105 | second_job = LongJob.perform_in(3600) 106 | expect(second_job).to eq(job_id_1) 107 | 108 | initial_schedule = redis.zrange "schedule", 0, -1, withscores: true 109 | expect(initial_schedule.size).to be(2) 110 | expect(initial_schedule.select {|scheduled_job| JSON.parse(scheduled_job[0])["jid"] == job_id }.size).to be(1) 111 | 112 | expect(Sidekiq::Status.unschedule(job_id)).to be_truthy 113 | # Unused, therefore unfound => false 114 | expect(Sidekiq::Status.cancel(unused_id)).to be_falsey 115 | 116 | remaining_schedule = redis.zrange "schedule", 0, -1, withscores: true 117 | expect(remaining_schedule.size).to be(initial_schedule.size - 1) 118 | expect(remaining_schedule.select {|scheduled_job| JSON.parse(scheduled_job[0])["jid"] == job_id }.size).to be(0) 119 | end 120 | end 121 | 122 | it "does not cancel a job with correct id but wrong time" do 123 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 124 | start_server do 125 | scheduled_time = Time.now.to_i + 3600 126 | returned_job_id = LongJob.perform_at(scheduled_time) 127 | expect(returned_job_id).to eq(job_id) 128 | 129 | initial_schedule = redis.zrange "schedule", 0, -1, withscores: true 130 | expect(initial_schedule.size).to be(1) 131 | # wrong time, therefore unfound => false 132 | expect(Sidekiq::Status.cancel(returned_job_id, (scheduled_time + 1))).to be_falsey 133 | expect((redis.zrange "schedule", 0, -1, withscores: true).size).to be(1) 134 | # same id, same time, deletes 135 | expect(Sidekiq::Status.cancel(returned_job_id, (scheduled_time))).to be_truthy 136 | expect(redis.zrange "schedule", 0, -1, withscores: true).to be_empty 137 | end 138 | end 139 | end 140 | 141 | describe ".stop!" do 142 | it "allows a job to be stopped" do 143 | allow(SecureRandom).to receive(:hex).once.and_return(job_id) 144 | start_server do 145 | expect(capture_status_updates(1) { 146 | expect(LongProgressJob.perform_async).to eq(job_id) 147 | expect(Sidekiq::Status.stop!(job_id)).to be_truthy 148 | }).to eq([job_id]*1) 149 | end 150 | expect(Sidekiq::Status.at(job_id)).to be(10) 151 | expect(Sidekiq::Status.stopped?(job_id)).to be_truthy 152 | end 153 | end 154 | 155 | context "keeps normal Sidekiq functionality" do 156 | let(:expiration_param) { nil } 157 | 158 | it "does jobs with and without included worker module" do 159 | seed_secure_random_with_job_ids 160 | run_2_jobs! 161 | expect_2_jobs_are_done_and_status_eq :complete 162 | expect_2_jobs_ttl_covers 1..Sidekiq::Status::DEFAULT_EXPIRY 163 | end 164 | 165 | it "does jobs without a known class" do 166 | seed_secure_random_with_job_ids 167 | start_server(:expiration => expiration_param) do 168 | expect { 169 | Sidekiq::Client.new(Sidekiq.redis_pool). 170 | push("class" => "NotAKnownClass", "args" => []) 171 | }.to_not raise_error 172 | end 173 | end 174 | 175 | it "retries failed jobs" do 176 | allow(SecureRandom).to receive(:hex).exactly(3).times.and_return(retried_job_id) 177 | start_server do 178 | expect(capture_status_updates(3) { 179 | expect(RetriedJob.perform_async()).to eq(retried_job_id) 180 | }).to eq([retried_job_id] * 3) 181 | expect(Sidekiq::Status.status(retried_job_id)).to eq(:retrying) 182 | expect(Sidekiq::Status.working?(retried_job_id)).to be_falsey 183 | expect(Sidekiq::Status::queued?(retried_job_id)).to be_falsey 184 | expect(Sidekiq::Status::retrying?(retried_job_id)).to be_truthy 185 | expect(Sidekiq::Status::failed?(retried_job_id)).to be_falsey 186 | expect(Sidekiq::Status::complete?(retried_job_id)).to be_falsey 187 | expect(Sidekiq::Status::stopped?(retried_job_id)).to be_falsey 188 | expect(Sidekiq::Status::interrupted?(retried_job_id)).to be_falsey 189 | end 190 | expect(Sidekiq::Status.status(retried_job_id)).to eq(:retrying) 191 | expect(Sidekiq::Status::retrying?(retried_job_id)).to be_truthy 192 | 193 | # restarting and waiting for the job to complete 194 | start_server do 195 | expect(capture_status_updates(3) {}).to eq([retried_job_id] * 3) 196 | expect(Sidekiq::Status.status(retried_job_id)).to eq(:complete) 197 | expect(Sidekiq::Status.complete?(retried_job_id)).to be_truthy 198 | expect(Sidekiq::Status::retrying?(retried_job_id)).to be_falsey 199 | end 200 | end 201 | 202 | it "marks retried jobs as failed once they do eventually fail" do 203 | allow(SecureRandom).to receive(:hex).and_return(retry_and_fail_job_id) 204 | start_server do 205 | expect( 206 | capture_status_updates(3) { 207 | expect(RetryAndFailJob.perform_async).to eq(retry_and_fail_job_id) 208 | } 209 | ).to eq([retry_and_fail_job_id] * 3) 210 | 211 | expect(Sidekiq::Status.status(retry_and_fail_job_id)).to eq(:retrying) 212 | end 213 | 214 | # restarting and waiting for the job to fail 215 | start_server do 216 | expect(capture_status_updates(3) {}).to eq([retry_and_fail_job_id] * 3) 217 | 218 | expect(Sidekiq::Status.status(retry_and_fail_job_id)).to eq(:failed) 219 | expect(Sidekiq::Status.failed?(retry_and_fail_job_id)).to be_truthy 220 | expect(Sidekiq::Status::retrying?(retry_and_fail_job_id)).to be_falsey 221 | end 222 | end 223 | 224 | context ":expiration param" do 225 | before { seed_secure_random_with_job_ids } 226 | let(:expiration_param) { Sidekiq::Status::DEFAULT_EXPIRY * 100 } 227 | 228 | it "allow to overwrite :expiration parameter" do 229 | run_2_jobs! 230 | expect_2_jobs_are_done_and_status_eq :complete 231 | expect_2_jobs_ttl_covers (Sidekiq::Status::DEFAULT_EXPIRY+1)..expiration_param 232 | end 233 | 234 | it "allow to overwrite :expiration parameter by #expiration method from worker" do 235 | overwritten_expiration = expiration_param * 100 236 | allow_any_instance_of(NoStatusConfirmationJob).to receive(:expiration). 237 | and_return(overwritten_expiration) 238 | allow_any_instance_of(StubJob).to receive(:expiration). 239 | and_return(overwritten_expiration) 240 | run_2_jobs! 241 | expect_2_jobs_are_done_and_status_eq :complete 242 | expect_2_jobs_ttl_covers (expiration_param+1)..overwritten_expiration 243 | end 244 | 245 | it "reads #expiration from a method when defined" do 246 | allow(SecureRandom).to receive(:hex).once.and_return(job_id, job_id_1) 247 | start_server do 248 | expect(StubJob.perform_async).to eq(job_id) 249 | expect(ExpiryJob.perform_async).to eq(job_id_1) 250 | expect(redis.ttl("sidekiq:status:#{job_id}")).to eq(30 * 60) 251 | expect(redis.ttl("sidekiq:status:#{job_id_1}")).to eq(15) 252 | end 253 | end 254 | end 255 | 256 | def seed_secure_random_with_job_ids 257 | allow(SecureRandom).to receive(:hex).exactly(4).times. 258 | and_return(plain_sidekiq_job_id, plain_sidekiq_job_id, job_id_1, job_id_1) 259 | end 260 | 261 | def run_2_jobs! 262 | start_server(:expiration => expiration_param) do 263 | expect(capture_status_updates(6) { 264 | expect(StubJob.perform_async).to eq(plain_sidekiq_job_id) 265 | NoStatusConfirmationJob.perform_async(1) 266 | expect(StubJob.perform_async).to eq(job_id_1) 267 | NoStatusConfirmationJob.perform_async(2) 268 | }).to match_array([plain_sidekiq_job_id, job_id_1] * 3) 269 | end 270 | end 271 | 272 | def expect_2_jobs_ttl_covers(range) 273 | expect(range).to cover redis.ttl("sidekiq:status:#{plain_sidekiq_job_id}") 274 | expect(range).to cover redis.ttl("sidekiq:status:#{job_id_1}") 275 | end 276 | 277 | def expect_2_jobs_are_done_and_status_eq(status) 278 | expect(redis.mget('NoStatusConfirmationJob_1', 'NoStatusConfirmationJob_2')).to eq(%w(done)*2) 279 | expect(Sidekiq::Status.status(plain_sidekiq_job_id)).to eq(status) 280 | expect(Sidekiq::Status.status(job_id_1)).to eq(status) 281 | end 282 | end 283 | 284 | end 285 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "rspec" 2 | require 'colorize' 3 | require 'sidekiq' 4 | 5 | # Celluloid should only be manually required before Sidekiq versions 4.+ 6 | require 'sidekiq/version' 7 | require 'celluloid' if Gem::Version.new(Sidekiq::VERSION) < Gem::Version.new('4.0') 8 | 9 | require 'sidekiq/processor' 10 | require 'sidekiq/manager' 11 | require 'sidekiq-status' 12 | 13 | # Clears jobs before every test 14 | RSpec.configure do |config| 15 | config.before(:each) do 16 | Sidekiq.redis { |conn| conn.flushall } 17 | client_middleware 18 | sleep 0.05 19 | end 20 | end 21 | 22 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 23 | 24 | # Configures client middleware 25 | def client_middleware client_middleware_options = {} 26 | Sidekiq.configure_client do |config| 27 | Sidekiq::Status.configure_client_middleware config, client_middleware_options 28 | end 29 | end 30 | 31 | def redis_thread messages_limit, *channels 32 | 33 | parent = Thread.current 34 | thread = Thread.new { 35 | messages = [] 36 | Sidekiq.redis do |conn| 37 | puts "Subscribing to #{channels} for #{messages_limit.to_s.bold} messages".cyan if ENV['DEBUG'] 38 | conn.subscribe_with_timeout 60, *channels do |on| 39 | on.subscribe do |ch, subscriptions| 40 | puts "Subscribed to #{ch}".cyan if ENV['DEBUG'] 41 | if subscriptions == channels.size 42 | sleep 0.1 while parent.status != "sleep" 43 | parent.run 44 | end 45 | end 46 | on.message do |ch, msg| 47 | puts "Message received: #{ch} -> #{msg}".white if ENV['DEBUG'] 48 | messages << msg 49 | conn.unsubscribe if messages.length >= messages_limit 50 | end 51 | end 52 | end 53 | puts "Returing from thread".cyan if ENV['DEBUG'] 54 | messages 55 | } 56 | 57 | Thread.stop 58 | yield if block_given? 59 | thread 60 | 61 | end 62 | 63 | def redis_client_thread message_limit, *channels 64 | thread = Thread.new { 65 | messages = [] 66 | Sidekiq.redis do |conn| 67 | puts "Subscribing to #{channels} for #{message_limit.to_s.bold} messages".cyan if ENV['DEBUG'] 68 | pubsub = conn.pubsub 69 | pubsub.call("SUBSCRIBE", *channels) 70 | 71 | timeouts = 0 72 | loop do 73 | type, ch, msg = pubsub.next_event(2) 74 | next if type == "subscribe" 75 | if msg 76 | puts "Message received: #{ch} -> #{msg}".white if ENV['DEBUG'] 77 | messages << msg 78 | break if messages.length >= message_limit 79 | else 80 | # no new message was received in the allocated timeout 81 | timeouts += 1 82 | break if timeouts >= 30 83 | end 84 | end 85 | end 86 | puts "Returing from thread".cyan if ENV['DEBUG'] 87 | messages 88 | } 89 | sleep 0.1 90 | yield if block_given? 91 | thread.join 92 | end 93 | 94 | def branched_redis_thread n, *channels, &block 95 | if Sidekiq.major_version < 7 96 | redis_thread(n, *channels, &block) 97 | else 98 | redis_client_thread(n, *channels, &block) 99 | end 100 | end 101 | 102 | def capture_status_updates n, &block 103 | branched_redis_thread(n, "status_updates", &block).value 104 | end 105 | 106 | # Configures server middleware and launches a sidekiq server 107 | def start_server server_middleware_options = {} 108 | 109 | # Creates a process for the Sidekiq server 110 | pid = Process.fork do 111 | 112 | # Redirect the server's outputs 113 | $stdout.reopen File::NULL, 'w' unless ENV['DEBUG'] 114 | $stderr.reopen File::NULL, 'w' unless ENV['DEBUG'] 115 | 116 | # Load and configure server options 117 | require 'sidekiq/cli' 118 | 119 | # Add the server middleware 120 | Sidekiq.configure_server do |config| 121 | config.concurrency = 5 122 | config.redis = Sidekiq::RedisConnection.create if Sidekiq.major_version < 7 123 | Sidekiq::Status.configure_server_middleware config, server_middleware_options 124 | end 125 | 126 | # Launch 127 | puts "Server starting".yellow if ENV['DEBUG'] 128 | instance = Sidekiq::CLI.instance 129 | instance.parse(['-r', File.expand_path('environment.rb', File.dirname(__FILE__))]) 130 | instance.run 131 | 132 | end 133 | 134 | # Run the client-side code 135 | yield 136 | 137 | # Pause to ensure all jobs are picked up & started before TERM is sent 138 | sleep 0.2 139 | 140 | # Attempt to shut down the server normally 141 | Process.kill 'TERM', pid 142 | Process.wait pid 143 | 144 | ensure 145 | 146 | # Ensure the server is actually dead 147 | Process.kill 'KILL', pid rescue "OK" # it's OK if the process is gone already 148 | 149 | end 150 | -------------------------------------------------------------------------------- /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 | 10.times do 38 | at 10 39 | sleep (args[0] || 0.25) / 10 40 | end 41 | end 42 | end 43 | 44 | class DataJob < StubJob 45 | def perform 46 | sleep 0.1 47 | store({data: 'meow'}) 48 | retrieve(:data).should == 'meow' 49 | sleep 0.1 50 | end 51 | end 52 | 53 | class CustomDataJob < StubJob 54 | def perform 55 | store({mister_cat: 'meow'}) 56 | sleep 0.5 57 | end 58 | end 59 | 60 | class ProgressJob < StubJob 61 | def perform 62 | total 500 63 | at 100, 'howdy, partner?' 64 | sleep 0.1 65 | end 66 | end 67 | 68 | class ConfirmationJob < StubJob 69 | def perform(*args) 70 | Sidekiq.redis do |conn| 71 | conn.publish "job_messages_#{jid}", "while in #perform, status = #{conn.hget "sidekiq:status:#{jid}", :status}" 72 | end 73 | end 74 | end 75 | 76 | class NoStatusConfirmationJob 77 | include Sidekiq::Worker 78 | def perform(id) 79 | Sidekiq.redis do |conn| 80 | conn.set "NoStatusConfirmationJob_#{id}", "done" 81 | end 82 | end 83 | end 84 | 85 | class FailingJob < StubJob 86 | def perform 87 | raise StandardError 88 | end 89 | end 90 | 91 | class FailingNoStatusJob < StubNoStatusJob 92 | def perform 93 | raise StandardError 94 | end 95 | end 96 | 97 | class RetryAndFailJob < StubJob 98 | sidekiq_options retry: 1 99 | 100 | def perform 101 | raise StandardError 102 | end 103 | end 104 | 105 | class FailingHardJob < StubJob 106 | def perform 107 | raise Exception 108 | end 109 | end 110 | 111 | class FailingHardNoStatusJob < StubNoStatusJob 112 | def perform 113 | raise Exception 114 | end 115 | end 116 | 117 | class ExitedJob < StubJob 118 | def perform 119 | raise SystemExit 120 | end 121 | end 122 | 123 | class ExitedNoStatusJob < StubNoStatusJob 124 | def perform 125 | raise SystemExit 126 | end 127 | end 128 | 129 | class InterruptedJob < StubJob 130 | def perform 131 | raise Interrupt 132 | end 133 | end 134 | 135 | class InterruptedNoStatusJob < StubNoStatusJob 136 | def perform 137 | raise Interrupt 138 | end 139 | end 140 | 141 | class RetriedJob < StubJob 142 | 143 | sidekiq_options retry: true 144 | sidekiq_retry_in do |count| 3 end # 3 second delay > job timeout in test suite 145 | 146 | def perform 147 | Sidekiq.redis do |conn| 148 | key = "RetriedJob_#{jid}" 149 | if [0, false].include? conn.exists(key) 150 | conn.set key, 'tried' 151 | raise StandardError 152 | end 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /web/sidekiq-status-single-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenaniah/sidekiq-status/86bdcb8f81909334cbd1ed4a731100e853b28e14/web/sidekiq-status-single-web.png -------------------------------------------------------------------------------- /web/sidekiq-status-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenaniah/sidekiq-status/86bdcb8f81909334cbd1ed4a731100e853b28e14/web/sidekiq-status-web.png -------------------------------------------------------------------------------- /web/views/status.erb: -------------------------------------------------------------------------------- 1 | <% require 'cgi'; def h(v); CGI.escape_html(v.to_s); end %> 2 | 16 | 17 |
<%= @status["args"].empty? ? "none" : h(@status["args"]) %>
42 |<%= h(@status["message"]) || "none" %>
51 |60 | <% secs = Time.now.to_i - @status["update_time"].to_i %> 61 | <% if secs > 0 %> 62 | <%= ChronicDuration.output(secs, :weeks => true, :units => 2) %> ago 63 | <% else %> 64 | Now 65 | <% end %> 66 |
67 |76 | <% if @status["elapsed"] %> 77 | <%= ChronicDuration.output(@status["elapsed"].to_i, :weeks => true, :units => 2) %> 78 | <% end %> 79 |
80 |89 | <% if @status["eta"] %> 90 | <%= ChronicDuration.output(@status["eta"].to_i, :weeks => true, :units => 2) %> 91 | <% end %> 92 |
93 |<%= h val %>106 | <% else %> 107 |
<%= h(val) || "none" %>
108 | <% end %> 109 |84 | <%= h hdr[:name] %> 85 | | 86 | <% end %> 87 |88 | Actions 89 | | 90 |||||||
---|---|---|---|---|---|---|---|
94 | 95 | | 96 |
97 | <%= h container["args"] %>
98 | |
99 |
100 | <%= h container["status"] %>
101 | |
102 | <% secs = Time.now.to_i - container["update_time"].to_i %>
103 | "> 104 | <% if secs > 0 %> 105 | <%= ChronicDuration.output(secs, :weeks => true, :units => 2) %> ago 106 | <% else %> 107 | Now 108 | <% end %> 109 | | 110 |
111 |
112 |
121 |
115 | <% if container["pct_complete"].to_i > 0 %>
116 |
119 | <% end %>
120 | |
122 | 123 | <% if container["elapsed"] %> 124 | <%= ChronicDuration.output(container["elapsed"].to_i, :weeks => true, :units => 2) %> 125 | <% end %> 126 | | 127 |128 | <% if container["eta"] %> 129 | <%= ChronicDuration.output(container["eta"].to_i, :weeks => true, :units => 2) %> 130 | <% end %> 131 | | 132 |
133 |
134 |
145 |
146 | |
147 |
152 | |