diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 29ab3fec..d02d9e8f 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Ruby and install gems uses: ruby/setup-ruby@v1 with: - ruby-version: 2.7.2 + ruby-version: ['3.0'] bundler-cache: true - name: Run rubocop run: | diff --git a/.gitignore b/.gitignore index a3ee5aad..d5ed50d6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,6 @@ /tmp/ /test/dummy/db/*.sqlite3 /test/dummy/db/*.sqlite3-* -/test/dummy/log/*.log +/test/dummy/log/*.log* /test/dummy/storage/ /test/dummy/tmp/ diff --git a/Gemfile b/Gemfile index 9f5a0955..d83df59d 100644 --- a/Gemfile +++ b/Gemfile @@ -11,3 +11,4 @@ gem "sprockets-rails" gem "solid_queue", bc: "solid_queue", require: false gem "rubocop-37signals", bc: "house-style", require: false gem "puma" +gem "capybara", github: "teamcapybara/capybara" diff --git a/Gemfile.lock b/Gemfile.lock index d57b822c..c4f61baa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,129 +10,153 @@ GIT GIT remote: https://github.com/basecamp/solid_queue - revision: d844a308e5c62b3d8f1942491c5433aca83dd2a9 + revision: 5ad872727251c32c282a7eb44df1e24068a3fdaa specs: - solid_queue (0.1.0) - rails (>= 7.0.3.1) + solid_queue (0.2.0) + rails (~> 7.1) + +GIT + remote: https://github.com/teamcapybara/capybara.git + revision: 52eaecea6d154b7d664b0032cd1cbcad4788fe65 + specs: + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) PATH remote: . specs: mission_control-jobs (0.1.0) importmap-rails - rails (>= 7.0.3.1) + rails (~> 7.1) stimulus-rails turbo-rails GEM remote: https://rubygems.org/ specs: - actioncable (7.0.3.1) - actionpack (= 7.0.3.1) - activesupport (= 7.0.3.1) + actioncable (7.1.3) + actionpack (= 7.1.3) + activesupport (= 7.1.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.3.1) - actionpack (= 7.0.3.1) - activejob (= 7.0.3.1) - activerecord (= 7.0.3.1) - activestorage (= 7.0.3.1) - activesupport (= 7.0.3.1) + zeitwerk (~> 2.6) + actionmailbox (7.1.3) + actionpack (= 7.1.3) + activejob (= 7.1.3) + activerecord (= 7.1.3) + activestorage (= 7.1.3) + activesupport (= 7.1.3) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.3.1) - actionpack (= 7.0.3.1) - actionview (= 7.0.3.1) - activejob (= 7.0.3.1) - activesupport (= 7.0.3.1) + actionmailer (7.1.3) + actionpack (= 7.1.3) + actionview (= 7.1.3) + activejob (= 7.1.3) + activesupport (= 7.1.3) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp - rails-dom-testing (~> 2.0) - actionpack (7.0.3.1) - actionview (= 7.0.3.1) - activesupport (= 7.0.3.1) - rack (~> 2.0, >= 2.2.0) + rails-dom-testing (~> 2.2) + actionpack (7.1.3) + actionview (= 7.1.3) + activesupport (= 7.1.3) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.3.1) - actionpack (= 7.0.3.1) - activerecord (= 7.0.3.1) - activestorage (= 7.0.3.1) - activesupport (= 7.0.3.1) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.3) + actionpack (= 7.1.3) + activerecord (= 7.1.3) + activestorage (= 7.1.3) + activesupport (= 7.1.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.3.1) - activesupport (= 7.0.3.1) + actionview (7.1.3) + activesupport (= 7.1.3) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.3.1) - activesupport (= 7.0.3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.3) + activesupport (= 7.1.3) globalid (>= 0.3.6) - activemodel (7.0.3.1) - activesupport (= 7.0.3.1) - activerecord (7.0.3.1) - activemodel (= 7.0.3.1) - activesupport (= 7.0.3.1) - activestorage (7.0.3.1) - actionpack (= 7.0.3.1) - activejob (= 7.0.3.1) - activerecord (= 7.0.3.1) - activesupport (= 7.0.3.1) + activemodel (7.1.3) + activesupport (= 7.1.3) + activerecord (7.1.3) + activemodel (= 7.1.3) + activesupport (= 7.1.3) + timeout (>= 0.4.0) + activestorage (7.1.3) + actionpack (= 7.1.3) + activejob (= 7.1.3) + activerecord (= 7.1.3) + activesupport (= 7.1.3) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (7.0.3.1) + activesupport (7.1.3) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.5) builder (3.2.4) - capybara (3.37.1) - addressable - matrix - mini_mime (>= 0.1.3) - nokogiri (~> 1.8) - rack (>= 1.6.0) - rack-test (>= 0.6.3) - regexp_parser (>= 1.5, < 3.0) - xpath (~> 3.2) - childprocess (4.1.0) concurrent-ruby (1.1.10) + connection_pool (2.4.1) crass (1.0.6) + debug (1.9.1) + irb (~> 1.10) + reline (>= 0.3.8) digest (3.1.0) + drb (2.2.0) + ruby2_keywords erubi (1.11.0) - globalid (1.0.0) - activesupport (>= 5.0) + globalid (1.2.1) + activesupport (>= 6.1) i18n (1.12.0) concurrent-ruby (~> 1.0) - importmap-rails (1.1.5) + importmap-rails (2.0.1) actionpack (>= 6.0.0) + activesupport (>= 6.0.0) railties (>= 6.0.0) - json (2.6.2) - loofah (2.18.0) + io-console (0.7.1) + irb (1.11.1) + rdoc + reline (>= 0.4.2) + json (2.7.1) + loofah (2.22.0) crass (~> 1.0.2) - nokogiri (>= 1.5.9) + nokogiri (>= 1.12.0) mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.2) matrix (0.4.2) - method_source (1.0.0) mini_mime (1.1.2) minitest (5.16.2) mocha (1.14.0) mono_logger (1.1.1) multi_json (1.15.0) - mustermann (2.0.2) - ruby2_keywords (~> 0.0.1) + mutex_m (0.2.0) net-imap (0.2.3) digest net-protocol @@ -148,55 +172,68 @@ GEM net-protocol timeout nio4r (2.5.8) - nokogiri (1.13.8-arm64-darwin) + nokogiri (1.16.0-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.8-x86_64-linux) + nokogiri (1.16.0-x86_64-linux) racc (~> 1.4) - parallel (1.22.1) - parser (3.1.2.0) + parallel (1.24.0) + parser (3.3.0.4) ast (~> 2.4.1) - public_suffix (4.0.7) + racc + psych (5.1.2) + stringio + public_suffix (5.0.4) puma (5.6.4) nio4r (~> 2.0) racc (1.6.0) - rack (2.2.4) - rack-protection (2.2.2) - rack - rack-test (2.0.2) + rack (3.0.8) + rack-session (2.0.0) + rack (>= 3.0.0) + rack-test (2.1.0) rack (>= 1.3) - rails (7.0.3.1) - actioncable (= 7.0.3.1) - actionmailbox (= 7.0.3.1) - actionmailer (= 7.0.3.1) - actionpack (= 7.0.3.1) - actiontext (= 7.0.3.1) - actionview (= 7.0.3.1) - activejob (= 7.0.3.1) - activemodel (= 7.0.3.1) - activerecord (= 7.0.3.1) - activestorage (= 7.0.3.1) - activesupport (= 7.0.3.1) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + rails (7.1.3) + actioncable (= 7.1.3) + actionmailbox (= 7.1.3) + actionmailer (= 7.1.3) + actionpack (= 7.1.3) + actiontext (= 7.1.3) + actionview (= 7.1.3) + activejob (= 7.1.3) + activemodel (= 7.1.3) + activerecord (= 7.1.3) + activestorage (= 7.1.3) + activesupport (= 7.1.3) bundler (>= 1.15.0) - railties (= 7.0.3.1) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + railties (= 7.1.3) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.4.3) - loofah (~> 2.3) - railties (7.0.3.1) - actionpack (= 7.0.3.1) - activesupport (= 7.0.3.1) - method_source + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + railties (7.1.3) + actionpack (= 7.1.3) + activesupport (= 7.1.3) + irb + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.0.6) + rdoc (6.6.2) + psych (>= 4.0.0) redis (4.0.3) redis-namespace (1.8.2) redis (>= 3.0.4) regexp_parser (2.5.0) - resque (2.2.1) + reline (0.4.2) + io-console (~> 0.5) + resque (2.6.0) mono_logger (~> 1.0) multi_json (~> 1.0) redis-namespace (~> 1.6) @@ -205,61 +242,59 @@ GEM multi_json (~> 1.0) resque (>= 1.9.10) rexml (3.2.5) - rubocop (1.32.0) + rubocop (1.52.1) json (~> 2.3) parallel (~> 1.10) - parser (>= 3.1.0.0) + parser (>= 3.2.2.3) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.19.1, < 2.0) + rubocop-ast (>= 1.28.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.19.1) - parser (>= 3.1.1.0) - rubocop-minitest (0.21.0) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.30.0) + parser (>= 3.2.1.0) + rubocop-minitest (0.27.0) rubocop (>= 0.90, < 2.0) - rubocop-performance (1.14.3) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-rails (2.15.2) + rubocop-performance (1.20.2) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rails (2.22.1) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.7.0, < 2.0) - ruby-progressbar (1.11.0) + rubocop (>= 1.33.0, < 2.0) + ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) - selenium-webdriver (4.3.0) - childprocess (>= 0.5, < 5.0) + selenium-webdriver (4.17.0) + base64 (~> 0.2) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sinatra (2.2.2) - mustermann (~> 2.0) - rack (~> 2.2) - rack-protection (= 2.2.2) - tilt (~> 2.0) - sprockets (4.1.1) + sinatra (1.0) + rack (>= 1.0) + sprockets (4.2.1) concurrent-ruby (~> 1.0) - rack (> 1, < 3) + rack (>= 2.2.4, < 4) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) sqlite3 (1.4.4) - stimulus-rails (1.1.0) + stimulus-rails (1.3.3) railties (>= 6.0.0) + stringio (3.1.0) strscan (3.0.4) - thor (1.2.1) - tilt (2.0.11) - timeout (0.3.0) - turbo-rails (1.1.1) + thor (1.3.0) + timeout (0.4.1) + turbo-rails (1.5.0) actionpack (>= 6.0.0) activejob (>= 6.0.0) railties (>= 6.0.0) tzinfo (2.0.5) concurrent-ruby (~> 1.0) - unicode-display_width (2.2.0) + unicode-display_width (2.5.0) + webrick (1.8.1) websocket (1.2.9) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) @@ -273,7 +308,8 @@ PLATFORMS x86_64-linux DEPENDENCIES - capybara + capybara! + debug mission_control-jobs! mocha puma @@ -281,7 +317,7 @@ DEPENDENCIES redis-namespace resque resque-pause - rubocop + rubocop (~> 1.52.0) rubocop-37signals! rubocop-performance rubocop-rails diff --git a/app/controllers/concerns/mission_control/jobs/adapter_features.rb b/app/controllers/concerns/mission_control/jobs/adapter_features.rb new file mode 100644 index 00000000..e92344f5 --- /dev/null +++ b/app/controllers/concerns/mission_control/jobs/adapter_features.rb @@ -0,0 +1,20 @@ +module MissionControl::Jobs::AdapterFeatures + extend ActiveSupport::Concern + + included do + helper_method :supported_job_statuses, :queue_pausing_supported?, :workers_exposed? + end + + private + def workers_exposed? + MissionControl::Jobs::Current.server.queue_adapter.exposes_workers? + end + + def supported_job_statuses + MissionControl::Jobs::Current.server.queue_adapter.supported_statuses & ActiveJob::JobsRelation::STATUSES + end + + def queue_pausing_supported? + MissionControl::Jobs::Current.server.queue_adapter.supports_queue_pausing? + end +end diff --git a/app/controllers/concerns/mission_control/jobs/failed_job_filtering.rb b/app/controllers/concerns/mission_control/jobs/failed_job_filtering.rb deleted file mode 100644 index 4ed7dd21..00000000 --- a/app/controllers/concerns/mission_control/jobs/failed_job_filtering.rb +++ /dev/null @@ -1,24 +0,0 @@ -module MissionControl::Jobs::FailedJobFiltering - extend ActiveSupport::Concern - - MAX_NUMBER_OF_JOBS_FOR_BULK_OPERATIONS = 3000 - - included do - before_action :set_job_class_filter - end - - private - def set_job_class_filter - @job_class_filter = params.dig(:filter, :job_class) - end - - def filtered_failed_jobs - ApplicationJob.jobs.failed.where(job_class: @job_class_filter) - end - - # We set a hard limit to prevent overloading redis. This should be enough for most scenarios. For - # cases where we need to retry a huge sets of jobs, we offer a runbook that uses the new API. - def bulk_limited_filtered_failed_jobs - filtered_failed_jobs.limit(MAX_NUMBER_OF_JOBS_FOR_BULK_OPERATIONS) - end -end diff --git a/app/controllers/concerns/mission_control/jobs/failed_job_scoped.rb b/app/controllers/concerns/mission_control/jobs/failed_job_scoped.rb deleted file mode 100644 index 5ecfbe44..00000000 --- a/app/controllers/concerns/mission_control/jobs/failed_job_scoped.rb +++ /dev/null @@ -1,12 +0,0 @@ -module MissionControl::Jobs::FailedJobScoped - extend ActiveSupport::Concern - - included do - before_action :set_job - end - - private - def set_job - @job = ActiveJob.jobs.failed.find_by_id!(params[:failed_job_id]) - end -end diff --git a/app/controllers/concerns/mission_control/jobs/failed_jobs_bulk_operations.rb b/app/controllers/concerns/mission_control/jobs/failed_jobs_bulk_operations.rb new file mode 100644 index 00000000..4ab944d3 --- /dev/null +++ b/app/controllers/concerns/mission_control/jobs/failed_jobs_bulk_operations.rb @@ -0,0 +1,17 @@ +module MissionControl::Jobs::FailedJobsBulkOperations + extend ActiveSupport::Concern + + MAX_NUMBER_OF_JOBS_FOR_BULK_OPERATIONS = 3000 + + included do + include MissionControl::Jobs::JobFilters + end + + private + # We set a hard limit to prevent problems with the data store (for example, overloading Redis + # or causing replication lag in MySQL). This should be enough for most scenarios. For + # cases where we need to retry a huge sets of jobs, we offer a runbook that uses the API. + def bulk_limited_filtered_failed_jobs + ApplicationJob.jobs.failed.where(**@job_filters).limit(MAX_NUMBER_OF_JOBS_FOR_BULK_OPERATIONS) + end +end diff --git a/app/controllers/concerns/mission_control/jobs/job_filters.rb b/app/controllers/concerns/mission_control/jobs/job_filters.rb new file mode 100644 index 00000000..01a171d4 --- /dev/null +++ b/app/controllers/concerns/mission_control/jobs/job_filters.rb @@ -0,0 +1,18 @@ +module MissionControl::Jobs::JobFilters + extend ActiveSupport::Concern + + included do + before_action :set_filters + + helper_method :active_filters? + end + + private + def set_filters + @job_filters = { job_class_name: params.dig(:filter, :job_class_name).presence, queue_name: params.dig(:filter, :queue_name).presence }.compact + end + + def active_filters? + @job_filters.any? + end +end diff --git a/app/controllers/concerns/mission_control/jobs/jobs_scoped.rb b/app/controllers/concerns/mission_control/jobs/job_scoped.rb similarity index 50% rename from app/controllers/concerns/mission_control/jobs/jobs_scoped.rb rename to app/controllers/concerns/mission_control/jobs/job_scoped.rb index 106bfa09..17883f38 100644 --- a/app/controllers/concerns/mission_control/jobs/jobs_scoped.rb +++ b/app/controllers/concerns/mission_control/jobs/job_scoped.rb @@ -1,13 +1,13 @@ -module MissionControl::Jobs::JobsScoped +module MissionControl::Jobs::JobScoped extend ActiveSupport::Concern included do - before_action :set_job, only: %i[ show ] + before_action :set_job, except: :index end private def set_job - @job = jobs_relation.find_by_id!(params[:id]) + @job = jobs_relation.find_by_id!(params[:job_id] || params[:id]) end def jobs_relation diff --git a/app/controllers/concerns/mission_control/jobs/not_found_redirections.rb b/app/controllers/concerns/mission_control/jobs/not_found_redirections.rb new file mode 100644 index 00000000..a3101d38 --- /dev/null +++ b/app/controllers/concerns/mission_control/jobs/not_found_redirections.rb @@ -0,0 +1,25 @@ +module MissionControl::Jobs::NotFoundRedirections + extend ActiveSupport::Concern + + included do + rescue_from(ActiveJob::Errors::JobNotFoundError) do |error| + redirect_to best_location_for_job_relation(error.job_relation), alert: error.message + end + + rescue_from(MissionControl::Jobs::Errors::ResourceNotFound) do |error| + redirect_to root_url, alert: error.message + end + end + + private + def best_location_for_job_relation(job_relation) + case + when job_relation.failed? + application_jobs_path(@application, :failed) + when job_relation.queue_name.present? + application_queue_path(@application, job_relation.queue_name) + else + root_path + end + end +end diff --git a/app/controllers/mission_control/jobs/application_controller.rb b/app/controllers/mission_control/jobs/application_controller.rb index 00ae66fa..ec07ab65 100644 --- a/app/controllers/mission_control/jobs/application_controller.rb +++ b/app/controllers/mission_control/jobs/application_controller.rb @@ -1,7 +1,8 @@ class MissionControl::Jobs::ApplicationController < MissionControl::Jobs.base_controller_class.constantize layout "mission_control/jobs/application" - include MissionControl::Jobs::ApplicationScoped + include MissionControl::Jobs::ApplicationScoped, MissionControl::Jobs::NotFoundRedirections + include MissionControl::Jobs::AdapterFeatures private def default_url_options diff --git a/app/controllers/mission_control/jobs/failed_jobs/bulk_discards_controller.rb b/app/controllers/mission_control/jobs/bulk_discards_controller.rb similarity index 56% rename from app/controllers/mission_control/jobs/failed_jobs/bulk_discards_controller.rb rename to app/controllers/mission_control/jobs/bulk_discards_controller.rb index 823a845a..085cc404 100644 --- a/app/controllers/mission_control/jobs/failed_jobs/bulk_discards_controller.rb +++ b/app/controllers/mission_control/jobs/bulk_discards_controller.rb @@ -1,16 +1,16 @@ -class MissionControl::Jobs::FailedJobs::BulkDiscardsController < MissionControl::Jobs::ApplicationController - include MissionControl::Jobs::FailedJobFiltering +class MissionControl::Jobs::BulkDiscardsController < MissionControl::Jobs::ApplicationController + include MissionControl::Jobs::FailedJobsBulkOperations def create jobs_to_discard_count = jobs_to_discard.count jobs_to_discard.discard_all - redirect_to application_failed_jobs_url(@application), notice: "Discarded #{jobs_to_discard_count} jobs" + redirect_to application_jobs_url(@application, :failed), notice: "Discarded #{jobs_to_discard_count} jobs" end private def jobs_to_discard - if @job_class_filter.present? + if active_filters? bulk_limited_filtered_failed_jobs else # we don't want to apply any limit since "discarding all" without parameters can be optimized in the adapter as a much faster operation diff --git a/app/controllers/mission_control/jobs/bulk_retries_controller.rb b/app/controllers/mission_control/jobs/bulk_retries_controller.rb new file mode 100644 index 00000000..320c0aa8 --- /dev/null +++ b/app/controllers/mission_control/jobs/bulk_retries_controller.rb @@ -0,0 +1,10 @@ +class MissionControl::Jobs::BulkRetriesController < MissionControl::Jobs::ApplicationController + include MissionControl::Jobs::FailedJobsBulkOperations + + def create + jobs_to_retry_count = bulk_limited_filtered_failed_jobs.count + bulk_limited_filtered_failed_jobs.retry_all + + redirect_to application_jobs_url(@application, :failed), notice: "Retried #{jobs_to_retry_count} jobs" + end +end diff --git a/app/controllers/mission_control/jobs/discards_controller.rb b/app/controllers/mission_control/jobs/discards_controller.rb new file mode 100644 index 00000000..5f0d356a --- /dev/null +++ b/app/controllers/mission_control/jobs/discards_controller.rb @@ -0,0 +1,13 @@ +class MissionControl::Jobs::DiscardsController < MissionControl::Jobs::ApplicationController + include MissionControl::Jobs::JobScoped + + def create + @job.discard + redirect_to application_jobs_url(@application, :failed), notice: "Discarded job with id #{@job.job_id}" + end + + private + def jobs_relation + ApplicationJob.jobs.failed + end +end diff --git a/app/controllers/mission_control/jobs/failed_jobs/bulk_retries_controller.rb b/app/controllers/mission_control/jobs/failed_jobs/bulk_retries_controller.rb deleted file mode 100644 index 4cb93531..00000000 --- a/app/controllers/mission_control/jobs/failed_jobs/bulk_retries_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class MissionControl::Jobs::FailedJobs::BulkRetriesController < MissionControl::Jobs::ApplicationController - include MissionControl::Jobs::FailedJobFiltering - - def create - jobs_to_retry_count = bulk_limited_filtered_failed_jobs.count - bulk_limited_filtered_failed_jobs.retry_all - - redirect_to application_failed_jobs_url(@application), notice: "Retried #{jobs_to_retry_count} jobs" - end -end diff --git a/app/controllers/mission_control/jobs/failed_jobs/discards_controller.rb b/app/controllers/mission_control/jobs/failed_jobs/discards_controller.rb deleted file mode 100644 index b40a434a..00000000 --- a/app/controllers/mission_control/jobs/failed_jobs/discards_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -class MissionControl::Jobs::FailedJobs::DiscardsController < MissionControl::Jobs::ApplicationController - include MissionControl::Jobs::FailedJobScoped - - def create - @job.discard - redirect_to application_failed_jobs_url(@application), notice: "Discarded job with id #{@job.job_id}" - end -end diff --git a/app/controllers/mission_control/jobs/failed_jobs/retries_controller.rb b/app/controllers/mission_control/jobs/failed_jobs/retries_controller.rb deleted file mode 100644 index ec3e35da..00000000 --- a/app/controllers/mission_control/jobs/failed_jobs/retries_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -class MissionControl::Jobs::FailedJobs::RetriesController < MissionControl::Jobs::ApplicationController - include MissionControl::Jobs::FailedJobScoped - - def create - @job.retry - redirect_to application_failed_jobs_url(@application), notice: "Retried job with id #{@job.job_id}" - end -end diff --git a/app/controllers/mission_control/jobs/failed_jobs_controller.rb b/app/controllers/mission_control/jobs/failed_jobs_controller.rb deleted file mode 100644 index 73538326..00000000 --- a/app/controllers/mission_control/jobs/failed_jobs_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -class MissionControl::Jobs::FailedJobsController < MissionControl::Jobs::ApplicationController - include MissionControl::Jobs::JobsScoped, MissionControl::Jobs::FailedJobFiltering - - def index - @job_classes = ApplicationJob.jobs.failed.job_classes - @jobs_page = MissionControl::Jobs::Page.new(filtered_failed_jobs, page: params[:page].to_i) - @jobs_count = @jobs_page.total_count - end - - def show - end - - private - def jobs_relation - ApplicationJob.jobs.failed - end -end diff --git a/app/controllers/mission_control/jobs/jobs_controller.rb b/app/controllers/mission_control/jobs/jobs_controller.rb new file mode 100644 index 00000000..48575ab8 --- /dev/null +++ b/app/controllers/mission_control/jobs/jobs_controller.rb @@ -0,0 +1,37 @@ +class MissionControl::Jobs::JobsController < MissionControl::Jobs::ApplicationController + include MissionControl::Jobs::JobScoped, MissionControl::Jobs::JobFilters + + def index + @job_class_names = jobs_with_status.job_class_names + @queue_names = ApplicationJob.queues.map(&:name) + + @jobs_page = MissionControl::Jobs::Page.new(filtered_jobs_with_status, page: params[:page].to_i) + @jobs_count = @jobs_page.total_count + end + + def show + end + + private + def jobs_relation + filtered_jobs + end + + def filtered_jobs_with_status + filtered_jobs.with_status(jobs_status) + end + + def jobs_with_status + ApplicationJob.jobs.with_status(jobs_status) + end + + def filtered_jobs + ApplicationJob.jobs.where(**@job_filters) + end + + helper_method :jobs_status + + def jobs_status + params[:status].presence&.inquiry + end +end diff --git a/app/controllers/mission_control/jobs/queues/jobs_controller.rb b/app/controllers/mission_control/jobs/queues/jobs_controller.rb deleted file mode 100644 index e5572ca0..00000000 --- a/app/controllers/mission_control/jobs/queues/jobs_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -class MissionControl::Jobs::Queues::JobsController < MissionControl::Jobs::ApplicationController - include MissionControl::Jobs::JobsScoped, MissionControl::Jobs::QueueScoped - - def show - end - - private - def jobs_relation - @queue.jobs.pending - end -end diff --git a/app/controllers/mission_control/jobs/queues/status_controller.rb b/app/controllers/mission_control/jobs/queues/pauses_controller.rb similarity index 75% rename from app/controllers/mission_control/jobs/queues/status_controller.rb rename to app/controllers/mission_control/jobs/queues/pauses_controller.rb index d3c58a35..4ae9dc36 100644 --- a/app/controllers/mission_control/jobs/queues/status_controller.rb +++ b/app/controllers/mission_control/jobs/queues/pauses_controller.rb @@ -1,13 +1,13 @@ -class MissionControl::Jobs::Queues::StatusController < MissionControl::Jobs::ApplicationController +class MissionControl::Jobs::Queues::PausesController < MissionControl::Jobs::ApplicationController include MissionControl::Jobs::QueueScoped - def pause + def create @queue.pause redirect_back fallback_location: application_queues_url(@application) end - def resume + def destroy @queue.resume redirect_back fallback_location: application_queues_url(@application) diff --git a/app/controllers/mission_control/jobs/queues_controller.rb b/app/controllers/mission_control/jobs/queues_controller.rb index 00867079..1be7a3fb 100644 --- a/app/controllers/mission_control/jobs/queues_controller.rb +++ b/app/controllers/mission_control/jobs/queues_controller.rb @@ -1,5 +1,5 @@ class MissionControl::Jobs::QueuesController < MissionControl::Jobs::ApplicationController - before_action :set_queue + before_action :set_queue, only: :show def index @queues = filtered_queues.sort_by(&:name) diff --git a/app/controllers/mission_control/jobs/retries_controller.rb b/app/controllers/mission_control/jobs/retries_controller.rb new file mode 100644 index 00000000..74c81128 --- /dev/null +++ b/app/controllers/mission_control/jobs/retries_controller.rb @@ -0,0 +1,13 @@ +class MissionControl::Jobs::RetriesController < MissionControl::Jobs::ApplicationController + include MissionControl::Jobs::JobScoped + + def create + @job.retry + redirect_to application_jobs_url(@application, :failed), notice: "Retried job with id #{@job.job_id}" + end + + private + def jobs_relation + ApplicationJob.jobs.failed + end +end diff --git a/app/controllers/mission_control/jobs/workers_controller.rb b/app/controllers/mission_control/jobs/workers_controller.rb new file mode 100644 index 00000000..58727cef --- /dev/null +++ b/app/controllers/mission_control/jobs/workers_controller.rb @@ -0,0 +1,18 @@ +class MissionControl::Jobs::WorkersController < MissionControl::Jobs::ApplicationController + before_action :ensure_exposed_workers + + def index + @workers = MissionControl::Jobs::Current.server.workers.sort_by { |worker| -worker.jobs.count } + end + + def show + @worker = MissionControl::Jobs::Current.server.find_worker(params[:id]) + end + + private + def ensure_exposed_workers + unless workers_exposed? + redirect_to root_url, alert: "This server doesn't expose workers" + end + end +end diff --git a/app/helpers/mission_control/jobs/dates_helper.rb b/app/helpers/mission_control/jobs/dates_helper.rb index d20fc231..cc8bc020 100644 --- a/app/helpers/mission_control/jobs/dates_helper.rb +++ b/app/helpers/mission_control/jobs/dates_helper.rb @@ -2,4 +2,18 @@ module MissionControl::Jobs::DatesHelper def time_ago_in_words_with_title(time) tag.span time_ago_in_words(time), title: time.to_fs(:long) end + + def time_distance_in_words_with_title(time) + tag.span distance_of_time_in_words_to_now(time, include_seconds: true), title: "Since #{time.to_fs(:long)}" + end + + def bidirectional_time_distance_in_words_with_title(time) + time_distance = if time.past? + "#{distance_of_time_in_words_to_now(time, include_seconds: true)} ago" + else + "in #{distance_of_time_in_words_to_now(time, include_seconds: true)}" + end + + tag.span time_distance, title: time.to_fs(:long) + end end diff --git a/app/helpers/mission_control/jobs/jobs_helper.rb b/app/helpers/mission_control/jobs/jobs_helper.rb index eb5e5525..9c5a61af 100644 --- a/app/helpers/mission_control/jobs/jobs_helper.rb +++ b/app/helpers/mission_control/jobs/jobs_helper.rb @@ -1,16 +1,12 @@ module MissionControl::Jobs::JobsHelper def job_title(job) - job.class_name + job.job_class_name end def job_arguments(job) renderable_job_arguments_for(job).join(", ") end - def failed_jobs_count - ActiveJob.jobs.failed.count - end - def failed_job_error(job) "#{job.last_execution_error.error_class}: #{job.last_execution_error.message}" end @@ -19,6 +15,17 @@ def failed_job_backtrace(job) job.last_execution_error.backtrace.join("\n") end + def attribute_names_for_job_status(status) + case status.to_s + when "failed" then [ "Error", "" ] + when "blocked" then [ "Queue", "Blocked by", "Block expiry" ] + when "finished" then [ "Queue", "Finished" ] + when "scheduled" then [ "Queue", "Scheduled" ] + when "in_progress" then [ "Queue", "Run by", "Running for" ] + else [] + end + end + private def renderable_job_arguments_for(job) job.serialized_arguments.collect do |argument| diff --git a/app/helpers/mission_control/jobs/navigation_helper.rb b/app/helpers/mission_control/jobs/navigation_helper.rb index f1cbc818..e386766c 100644 --- a/app/helpers/mission_control/jobs/navigation_helper.rb +++ b/app/helpers/mission_control/jobs/navigation_helper.rb @@ -2,10 +2,21 @@ module MissionControl::Jobs::NavigationHelper attr_reader :page_title, :current_section def navigation_sections - { - queues: [ "Queues", application_queues_path(@application) ], - failed_jobs: [ "Failed jobs (#{failed_jobs_count})", application_failed_jobs_path(@application) ] - } + { queues: [ "Queues", application_queues_path(@application) ] }.tap do |sections| + supported_job_statuses.without(:pending).each do |status| + sections[navigation_section_for_status(status)] = [ "#{status.to_s.titleize} jobs (#{jobs_count_with_status(status)})", application_jobs_path(@application, status) ] + end + + sections[:workers] = [ "Workers", application_workers_path(@application) ] if workers_exposed? + end + end + + def navigation_section_for_status(status) + if status.nil? || status == :pending + :queues + else + "#{status}_jobs".to_sym + end end def navigation(title: nil, section: nil) @@ -13,10 +24,6 @@ def navigation(title: nil, section: nil) @current_section = section end - def page_title - @page_title - end - def selected_application?(application) MissionControl::Jobs::Current.application.name == application.name end @@ -30,10 +37,15 @@ def selected_server?(server) end def jobs_filter_param - if @job_class_filter - { filter: { job_class: @job_class_filter } } + if @job_filters&.any? + { filter: @job_filters } else {} end end + + def jobs_count_with_status(status) + count = ApplicationJob.jobs.with_status(status).count + count.infinite? ? "..." : number_to_human(count) + end end diff --git a/app/helpers/mission_control/jobs/ui_helper.rb b/app/helpers/mission_control/jobs/ui_helper.rb index 707554bd..ced2db0d 100644 --- a/app/helpers/mission_control/jobs/ui_helper.rb +++ b/app/helpers/mission_control/jobs/ui_helper.rb @@ -2,4 +2,22 @@ module MissionControl::Jobs::UiHelper def blank_status_notice(message) tag.div message, class: "mt-6 has-text-centered is-size-3 has-text-grey" end + + def blank_status_emoji(status) + case status.to_s + when "failed", "blocked" then "😌" + else "" + end + end + + def modifier_for_status(status) + case status.to_s + when "failed" then "is-danger" + when "blocked" then "is-warning" + when "finished" then "is-success" + when "scheduled" then "is-info" + when "in_progress" then "is-primary" + else "is-primary is-light" + end + end end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/models/mission_control/jobs/page.rb b/app/models/mission_control/jobs/page.rb index 7c76a0f8..e95070db 100644 --- a/app/models/mission_control/jobs/page.rb +++ b/app/models/mission_control/jobs/page.rb @@ -18,7 +18,7 @@ def first? end def last? - index == pages_count || empty? + index == pages_count || empty? || jobs.empty? end def empty? @@ -30,11 +30,11 @@ def previous_index end def next_index - [ index + 1, pages_count ].min + pages_count ? [ index + 1, pages_count ].min : index + 1 end def pages_count - (total_count.to_f / 10).ceil + (total_count.to_f / 10).ceil unless total_count.infinite? end def total_count diff --git a/app/models/mission_control/jobs/worker.rb b/app/models/mission_control/jobs/worker.rb new file mode 100644 index 00000000..4b693b1f --- /dev/null +++ b/app/models/mission_control/jobs/worker.rb @@ -0,0 +1,17 @@ +class MissionControl::Jobs::Worker + include ActiveModel::Model + + attr_accessor :id, :name, :hostname, :last_heartbeat_at, :configuration, :raw_data + + def initialize(queue_adapter: ActiveJob::Base.queue_adapter, **kwargs) + @queue_adapter = queue_adapter + super(**kwargs) + end + + def jobs + @jobs ||= ActiveJob::JobsRelation.new(queue_adapter: queue_adapter).in_progress.where(worker_id: id) + end + + private + attr_reader :queue_adapter +end diff --git a/app/views/mission_control/jobs/failed_jobs/_actions.html.erb b/app/views/mission_control/jobs/failed_jobs/_actions.html.erb deleted file mode 100644 index e8ea0c4d..00000000 --- a/app/views/mission_control/jobs/failed_jobs/_actions.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -
- <%= button_to "Discard", application_failed_job_discard_path(@application, job.job_id), class: "button is-danger is-light mr-0", - form: { data: { turbo_confirm: "This will delete the job and can't be undone. Are you sure?" } } %> - <%= button_to "Retry", application_failed_job_retry_path(@application, job.job_id), class: "button is-warning is-light mr-0" %> -
diff --git a/app/views/mission_control/jobs/failed_jobs/_filter.html.erb b/app/views/mission_control/jobs/failed_jobs/_filter.html.erb deleted file mode 100644 index d0b1e1c9..00000000 --- a/app/views/mission_control/jobs/failed_jobs/_filter.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -
-
-
- <%= form_for :filter, url: application_failed_jobs_path(@application), method: :get, - data: { controller: "form", action: "input->form#debouncedSubmit" } do |form| %> -
- <%= form.text_field :job_class, value: @job_class_filter, class: "input", list: "job-classes", placeholder: "Filter by job class..." %> - <%= hidden_field_tag :server_id, MissionControl::Jobs::Current.server.id %> -
- - -
-
- <%= link_to "Clear", application_failed_jobs_path(@application, job_class: nil), class: "button" %> -
- <% end %> -
-
diff --git a/app/views/mission_control/jobs/failed_jobs/_job.html.erb b/app/views/mission_control/jobs/failed_jobs/_job.html.erb deleted file mode 100644 index 664c80ee..00000000 --- a/app/views/mission_control/jobs/failed_jobs/_job.html.erb +++ /dev/null @@ -1,20 +0,0 @@ - - - <%= link_to application_failed_job_path(@application, job.job_id) do %> - <%= job_title(job) %> - <% end %> - - <% if job.serialized_arguments.present? %> -
- <%= job_arguments(job) %> -
- <% end %> - - - <%= link_to failed_job_error(job), application_failed_job_path(@application, job.job_id, anchor: "error") %> -
<%= time_ago_in_words_with_title(job.failed_at) %> ago
- - - <%= render "mission_control/jobs/failed_jobs/actions", job: job %> - - diff --git a/app/views/mission_control/jobs/failed_jobs/_toolbar.html.erb b/app/views/mission_control/jobs/failed_jobs/_toolbar.html.erb deleted file mode 100644 index cd93e06b..00000000 --- a/app/views/mission_control/jobs/failed_jobs/_toolbar.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -
- <% if @job_class_filter.present? %> - - <%= jobs_count %> jobs selected - - <% end %> - - <% target = @job_class_filter.present? ? "selection" : "all" %> - - <%= button_to "Discard #{target}", application_failed_jobs_bulk_discard_path(@application, **jobs_filter_param), - method: :post, disabled: jobs_count == 0, class: "button is-danger is-light", - form: { data: { turbo_confirm: "This will delete #{jobs_count} jobs and can't be undone. Are you sure?" } } %> - <%= button_to "Retry #{target}", application_failed_jobs_bulk_retry_path(@application, **jobs_filter_param), - method: :post, disabled: jobs_count == 0, - class: "button is-warning is-light mr-0" %> -
diff --git a/app/views/mission_control/jobs/failed_jobs/index.html.erb b/app/views/mission_control/jobs/failed_jobs/index.html.erb deleted file mode 100644 index f7973ea7..00000000 --- a/app/views/mission_control/jobs/failed_jobs/index.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<% navigation(title: "Failed jobs", section: :failed_jobs) %> - -<% if @jobs_page.empty? %> - <%= blank_status_notice "There are no failed jobs 😌" %> -<% else %> -
- <%= render "mission_control/jobs/failed_jobs/filter" %> - <%= render "mission_control/jobs/failed_jobs/toolbar", jobs_count: @jobs_count %> -
- - - - - - - - - - - - <%= render partial: "mission_control/jobs/failed_jobs/job", collection: @jobs_page.jobs %> - - -
JobError
- - <%= render "mission_control/jobs/shared/pagination_toolbar", jobs_page: @jobs_page %> -<% end %> - diff --git a/app/views/mission_control/jobs/failed_jobs/show.html.erb b/app/views/mission_control/jobs/failed_jobs/show.html.erb deleted file mode 100644 index 4aa79dc5..00000000 --- a/app/views/mission_control/jobs/failed_jobs/show.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% navigation(title: "Failed jobs", section: :failed_jobs) %> - -<%= render "mission_control/jobs/jobs/show", job: @job %> diff --git a/app/views/mission_control/jobs/jobs/_filters.html.erb b/app/views/mission_control/jobs/jobs/_filters.html.erb new file mode 100644 index 00000000..5421762f --- /dev/null +++ b/app/views/mission_control/jobs/jobs/_filters.html.erb @@ -0,0 +1,35 @@ +
+
+
+ <%= form_for :filter, url: application_jobs_path(MissionControl::Jobs::Current.application, jobs_status), method: :get, + data: { controller: "form", action: "input->form#debouncedSubmit" } do |form| %> + +
+ <%= form.text_field :job_class_name, value: @job_filters[:job_class_name], class: "input", list: "job-classes", placeholder: "Filter by job class..." %> +
+ +
+ <%= form.text_field :queue_name, value: @job_filters[:queue_name], class: "input", list: "queue-names", placeholder: "Filter by queue name..." %> +
+ + <%= hidden_field_tag :server_id, MissionControl::Jobs::Current.server.id %> + + + + + <% end %> +
+ +
+ <%= link_to "Clear", application_jobs_path(MissionControl::Jobs::Current.application, jobs_status, job_class_name: nil, queue_name: nil), class: "button" %> +
+
+
diff --git a/app/views/mission_control/jobs/jobs/_general_information.html.erb b/app/views/mission_control/jobs/jobs/_general_information.html.erb index ad5cc18a..7a5e0912 100644 --- a/app/views/mission_control/jobs/jobs/_general_information.html.erb +++ b/app/views/mission_control/jobs/jobs/_general_information.html.erb @@ -34,5 +34,21 @@ <% end %> + <% if job.finished_at.present? %> + + Finished at + + <%= time_ago_in_words_with_title(job.finished_at) %> ago + + + <% end %> + <% if job.worker_id.present? %> + + Processed by + + <%= link_to "worker #{job.worker_id}", application_worker_path(@application, job.worker_id) %> + + + <% end %> diff --git a/app/views/mission_control/jobs/jobs/_job.html.erb b/app/views/mission_control/jobs/jobs/_job.html.erb new file mode 100644 index 00000000..881eaba6 --- /dev/null +++ b/app/views/mission_control/jobs/jobs/_job.html.erb @@ -0,0 +1,13 @@ + + + <%= link_to job_title(job), application_job_path(@application, job.job_id) %> + + <% if job.serialized_arguments.present? %> +
<%= job_arguments(job) %>
+ <% end %> + +
Enqueued <%= time_ago_in_words_with_title(job.enqueued_at.to_datetime) %> ago
+ + + <%= render "mission_control/jobs/jobs/#{jobs_status}/job", job: job %> + diff --git a/app/views/mission_control/jobs/jobs/_jobs_page.html.erb b/app/views/mission_control/jobs/jobs/_jobs_page.html.erb new file mode 100644 index 00000000..806425fa --- /dev/null +++ b/app/views/mission_control/jobs/jobs/_jobs_page.html.erb @@ -0,0 +1,15 @@ + + + + + + <% attribute_names_for_job_status(jobs_status).each do |attribute| %> + + <% end %> + + + + <%= render partial: "mission_control/jobs/jobs/job", collection: jobs_page.jobs %> + + +
Job<%= attribute %>
diff --git a/app/views/mission_control/jobs/jobs/_show.html.erb b/app/views/mission_control/jobs/jobs/_show.html.erb deleted file mode 100644 index 239b1b3d..00000000 --- a/app/views/mission_control/jobs/jobs/_show.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -<%= render "mission_control/jobs/jobs/title", job: job %> -<%= render "mission_control/jobs/jobs/general_information", job: job %> -<%= render "mission_control/jobs/jobs/error_information", job: job %> -<%= render "mission_control/jobs/jobs/raw_data", job: job %> diff --git a/app/views/mission_control/jobs/jobs/_title.html.erb b/app/views/mission_control/jobs/jobs/_title.html.erb index 4ef36bb5..e2b8209f 100644 --- a/app/views/mission_control/jobs/jobs/_title.html.erb +++ b/app/views/mission_control/jobs/jobs/_title.html.erb @@ -2,13 +2,11 @@
<%= job_title(job) %> - <% if job.failed? %> -
failed
- <% end %> +
<%= job.status %>
<% if job.failed? %> - <%= render "mission_control/jobs/failed_jobs/actions", job: job %> + <%= render "mission_control/jobs/jobs/failed/actions", job: job %> <% end %>
diff --git a/app/views/mission_control/jobs/jobs/_toolbar.html.erb b/app/views/mission_control/jobs/jobs/_toolbar.html.erb new file mode 100644 index 00000000..89fbc939 --- /dev/null +++ b/app/views/mission_control/jobs/jobs/_toolbar.html.erb @@ -0,0 +1,18 @@ +
+ <% if active_filters? %> + + <%= jobs_count %> jobs found + + <% end %> + + <% if jobs_status.failed? %> + <% target = active_filters? ? "selection" : "all" %> + + <%= button_to "Discard #{target}", application_bulk_discards_path(@application, **jobs_filter_param), + method: :post, disabled: jobs_count == 0, class: "button is-danger is-light", + form: { data: { turbo_confirm: "This will delete #{jobs_count} jobs and can't be undone. Are you sure?" } } %> + <%= button_to "Retry #{target}", application_bulk_retries_path(@application, **jobs_filter_param), + method: :post, disabled: jobs_count == 0, + class: "button is-warning is-light mr-0" %> + <% end %> +
diff --git a/app/views/mission_control/jobs/jobs/blocked/_job.html.erb b/app/views/mission_control/jobs/jobs/blocked/_job.html.erb new file mode 100644 index 00000000..c4c1088b --- /dev/null +++ b/app/views/mission_control/jobs/jobs/blocked/_job.html.erb @@ -0,0 +1,3 @@ +<%= link_to job.queue_name, application_queue_path(@application, job.queue) %> +
<%= job.blocked_by %>
+<%= bidirectional_time_distance_in_words_with_title(job.blocked_until) %> diff --git a/app/views/mission_control/jobs/jobs/failed/_actions.html.erb b/app/views/mission_control/jobs/jobs/failed/_actions.html.erb new file mode 100644 index 00000000..80a2d2b5 --- /dev/null +++ b/app/views/mission_control/jobs/jobs/failed/_actions.html.erb @@ -0,0 +1,5 @@ +
+ <%= button_to "Discard", application_job_discard_path(@application, job.job_id), class: "button is-danger is-light mr-0", + form: { data: { turbo_confirm: "This will delete the job and can't be undone. Are you sure?" } } %> + <%= button_to "Retry", application_job_retry_path(@application, job.job_id), class: "button is-warning is-light mr-0" %> +
diff --git a/app/views/mission_control/jobs/jobs/failed/_job.html.erb b/app/views/mission_control/jobs/jobs/failed/_job.html.erb new file mode 100644 index 00000000..54f39510 --- /dev/null +++ b/app/views/mission_control/jobs/jobs/failed/_job.html.erb @@ -0,0 +1,7 @@ + + <%= link_to failed_job_error(job), application_job_path(@application, job.job_id, anchor: "error") %> +
<%= time_ago_in_words_with_title(job.failed_at) %> ago
+ + + <%= render "mission_control/jobs/jobs/failed/actions", job: job %> + diff --git a/app/views/mission_control/jobs/jobs/finished/_job.html.erb b/app/views/mission_control/jobs/jobs/finished/_job.html.erb new file mode 100644 index 00000000..3a6b7cfc --- /dev/null +++ b/app/views/mission_control/jobs/jobs/finished/_job.html.erb @@ -0,0 +1,2 @@ +<%= link_to job.queue_name, application_queue_path(@application, job.queue) %> +
<%= time_ago_in_words_with_title(job.finished_at) %> ago
diff --git a/app/views/mission_control/jobs/jobs/in_progress/_job.html.erb b/app/views/mission_control/jobs/jobs/in_progress/_job.html.erb new file mode 100644 index 00000000..48491725 --- /dev/null +++ b/app/views/mission_control/jobs/jobs/in_progress/_job.html.erb @@ -0,0 +1,9 @@ +<%= link_to job.queue_name, application_queue_path(@application, job.queue) %> + + <% if job.worker_id %> + <%= link_to "worker #{job.worker_id}", application_worker_path(@application, job.worker_id) %> + <% else %> + — + <% end %> + +
<%= job.started_at ? time_distance_in_words_with_title(job.started_at) : "(Finished)" %>
diff --git a/app/views/mission_control/jobs/jobs/index.html.erb b/app/views/mission_control/jobs/jobs/index.html.erb new file mode 100644 index 00000000..92c183e2 --- /dev/null +++ b/app/views/mission_control/jobs/jobs/index.html.erb @@ -0,0 +1,19 @@ +<% navigation(title: "#{jobs_status.titleize} jobs", section: "#{jobs_status}_jobs".to_sym) %> + +<% if @jobs_page.empty? && !active_filters? %> + <%= blank_status_notice "There are no #{jobs_status.dasherize} jobs #{blank_status_emoji(jobs_status)}" %> +<% else %> +
+ <%= render "mission_control/jobs/jobs/filters", job_class_names: @job_class_names, queue_names: @queue_names %> + <%= render "mission_control/jobs/jobs/toolbar", jobs_count: @jobs_count %> +
+ + <% if @jobs_page.empty? %> + <%= blank_status_notice "No #{jobs_status.dasherize} jobs found with the given filters" %> + <% else %> + <%= render "mission_control/jobs/jobs/jobs_page", jobs_page: @jobs_page %> + + <%= render "mission_control/jobs/shared/pagination_toolbar", jobs_page: @jobs_page %> + <% end %> +<% end %> + diff --git a/app/views/mission_control/jobs/jobs/scheduled/_job.html.erb b/app/views/mission_control/jobs/jobs/scheduled/_job.html.erb new file mode 100644 index 00000000..50924a4e --- /dev/null +++ b/app/views/mission_control/jobs/jobs/scheduled/_job.html.erb @@ -0,0 +1,2 @@ +<%= link_to job.queue_name, application_queue_path(@application, job.queue) %> +<%= bidirectional_time_distance_in_words_with_title(job.scheduled_at) %> diff --git a/app/views/mission_control/jobs/jobs/show.html.erb b/app/views/mission_control/jobs/jobs/show.html.erb new file mode 100644 index 00000000..e4835183 --- /dev/null +++ b/app/views/mission_control/jobs/jobs/show.html.erb @@ -0,0 +1,6 @@ +<% navigation(title: "Job #{@job.job_id}", section: navigation_section_for_status(@job.status)) %> + +<%= render "mission_control/jobs/jobs/title", job: @job %> +<%= render "mission_control/jobs/jobs/general_information", job: @job %> +<%= render "mission_control/jobs/jobs/error_information", job: @job %> +<%= render "mission_control/jobs/jobs/raw_data", job: @job %> diff --git a/app/views/mission_control/jobs/queues/_actions.html.erb b/app/views/mission_control/jobs/queues/_actions.html.erb index 4e5ea4e5..b394edd8 100644 --- a/app/views/mission_control/jobs/queues/_actions.html.erb +++ b/app/views/mission_control/jobs/queues/_actions.html.erb @@ -1,7 +1,7 @@
<% if queue.active? %> - <%= button_to "Pause", pause_application_queue_status_path(@application, queue.name), method: :put, class: "button is-success is-light mr-0" %> + <%= button_to "Pause", application_queue_pause_path(@application, queue.name), method: :post, class: "button is-success is-light mr-0" %> <% else %> - <%= button_to "Resume", resume_application_queue_status_path(@application, queue.name), method: :put, class: "button is-warning is-light mr-0" %> + <%= button_to "Resume", application_queue_pause_path(@application, queue.name), method: :delete, class: "button is-warning is-light mr-0" %> <% end %>
diff --git a/app/views/mission_control/jobs/queues/_job.html.erb b/app/views/mission_control/jobs/queues/_job.html.erb index 197bce12..36cd2c7b 100644 --- a/app/views/mission_control/jobs/queues/_job.html.erb +++ b/app/views/mission_control/jobs/queues/_job.html.erb @@ -1,6 +1,6 @@ - <%= link_to application_queue_job_path(MissionControl::Jobs::Current.application, job.queue, job.job_id) do %> + <%= link_to application_job_path(@application, job.job_id, filter: { queue_name: job.queue }) do %> <%= job_title(job) %> <% end %>
Enqueued <%= time_ago_in_words_with_title(job.enqueued_at.to_datetime) %> ago
diff --git a/app/views/mission_control/jobs/queues/_queue.html.erb b/app/views/mission_control/jobs/queues/_queue.html.erb index 4f8cfa88..18fbc317 100644 --- a/app/views/mission_control/jobs/queues/_queue.html.erb +++ b/app/views/mission_control/jobs/queues/_queue.html.erb @@ -9,6 +9,8 @@ <%= queue.size %> - <%= render "mission_control/jobs/queues/actions", queue: queue %> + <% if queue_pausing_supported? %> + <%= render "mission_control/jobs/queues/actions", queue: queue %> + <% end %> diff --git a/app/views/mission_control/jobs/queues/_queue_title.html.erb b/app/views/mission_control/jobs/queues/_queue_title.html.erb index 32688393..4b94e802 100644 --- a/app/views/mission_control/jobs/queues/_queue_title.html.erb +++ b/app/views/mission_control/jobs/queues/_queue_title.html.erb @@ -6,9 +6,11 @@ Paused <% end %> -
- <%= render "mission_control/jobs/queues/actions", queue: queue %> -
+ <% if queue_pausing_supported? %> +
+ <%= render "mission_control/jobs/queues/actions", queue: queue %> +
+ <% end %> diff --git a/app/views/mission_control/jobs/queues/index.html.erb b/app/views/mission_control/jobs/queues/index.html.erb index 66717772..7c0d8dfe 100644 --- a/app/views/mission_control/jobs/queues/index.html.erb +++ b/app/views/mission_control/jobs/queues/index.html.erb @@ -1,6 +1,6 @@ <% navigation(title: "Queues", section: :queues) %> - +
diff --git a/app/views/mission_control/jobs/queues/jobs/show.html.erb b/app/views/mission_control/jobs/queues/jobs/show.html.erb deleted file mode 100644 index e95a9a80..00000000 --- a/app/views/mission_control/jobs/queues/jobs/show.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% navigation(title: "Pending jobs in #{@queue.name}", section: :queues) %> - -<%= render "mission_control/jobs/jobs/show", job: @job %> diff --git a/app/views/mission_control/jobs/queues/show.html.erb b/app/views/mission_control/jobs/queues/show.html.erb index 922f04e3..6a60a764 100644 --- a/app/views/mission_control/jobs/queues/show.html.erb +++ b/app/views/mission_control/jobs/queues/show.html.erb @@ -5,11 +5,11 @@ <% if @jobs_page.empty? %> <%= blank_status_notice "The queue is empty" %> <% else %> -
+
- + diff --git a/app/views/mission_control/jobs/shared/_pagination_toolbar.html.erb b/app/views/mission_control/jobs/shared/_pagination_toolbar.html.erb index e12730e6..819a031a 100644 --- a/app/views/mission_control/jobs/shared/_pagination_toolbar.html.erb +++ b/app/views/mission_control/jobs/shared/_pagination_toolbar.html.erb @@ -1,5 +1,5 @@ diff --git a/app/views/mission_control/jobs/workers/_configuration.html.erb b/app/views/mission_control/jobs/workers/_configuration.html.erb new file mode 100644 index 00000000..e4470ca8 --- /dev/null +++ b/app/views/mission_control/jobs/workers/_configuration.html.erb @@ -0,0 +1,6 @@ +

Configuration

+
+<%= JSON.pretty_generate(worker.configuration) %>
+
+ +
diff --git a/app/views/mission_control/jobs/workers/_job.html.erb b/app/views/mission_control/jobs/workers/_job.html.erb new file mode 100644 index 00000000..c1a464e2 --- /dev/null +++ b/app/views/mission_control/jobs/workers/_job.html.erb @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/views/mission_control/jobs/workers/_jobs.html.erb b/app/views/mission_control/jobs/workers/_jobs.html.erb new file mode 100644 index 00000000..071b98e2 --- /dev/null +++ b/app/views/mission_control/jobs/workers/_jobs.html.erb @@ -0,0 +1,20 @@ +<% if @worker.jobs.empty? %> + <%= blank_status_notice "This worker is idle" %> +<% else %> +

Running <%= worker.jobs.size %> jobs

+ +
JobJob
+ <%= link_to application_job_path(@application, job.job_id, filter: { queue_name: job.queue }) do %> + <%= job_title(job) %> + <% end %> +
Enqueued <%= time_ago_in_words_with_title(job.enqueued_at.to_datetime) %> ago
+
+ <% if job.serialized_arguments.present? %> + + <%= job_arguments(job) %> + + <% end %> + +
<%= job.started_at ? time_distance_in_words_with_title(job.started_at) : "(Finished)" %>
+
+ + + + + + + + + + <%= render partial: "mission_control/jobs/workers/job", collection: @worker.jobs %> + + +
JobRunning for
+<% end %> diff --git a/app/views/mission_control/jobs/workers/_raw_data.html.erb b/app/views/mission_control/jobs/workers/_raw_data.html.erb new file mode 100644 index 00000000..3f083add --- /dev/null +++ b/app/views/mission_control/jobs/workers/_raw_data.html.erb @@ -0,0 +1,6 @@ +
+ +

Raw data

+
+  <%= JSON.pretty_generate(worker.raw_data) %>
+
diff --git a/app/views/mission_control/jobs/workers/_title.html.erb b/app/views/mission_control/jobs/workers/_title.html.erb new file mode 100644 index 00000000..318f5210 --- /dev/null +++ b/app/views/mission_control/jobs/workers/_title.html.erb @@ -0,0 +1,11 @@ +

+
+
+ <%= "Worker #{worker.id} — #{worker.name}" %> +
+ +
+ <%= worker.hostname %> +
+
+

diff --git a/app/views/mission_control/jobs/workers/_worker.html.erb b/app/views/mission_control/jobs/workers/_worker.html.erb new file mode 100644 index 00000000..be9eaadf --- /dev/null +++ b/app/views/mission_control/jobs/workers/_worker.html.erb @@ -0,0 +1,21 @@ + + + <%= link_to "worker #{worker.id}", application_worker_path(@application, worker.id) %> +
+ <%= worker.name %> + + <%= worker.hostname %> + + <% worker.jobs.each do |job| %> +
+ <%= link_to job_title(job), application_job_path(@application, job.job_id) %> + + <% if job.serialized_arguments.present? %> +
<%= job_arguments(job) %>
+ <% end %> +
+ <% end %> + + +
<%= time_ago_in_words_with_title(worker.last_heartbeat_at) %> ago
+ diff --git a/app/views/mission_control/jobs/workers/index.html.erb b/app/views/mission_control/jobs/workers/index.html.erb new file mode 100644 index 00000000..556680b9 --- /dev/null +++ b/app/views/mission_control/jobs/workers/index.html.erb @@ -0,0 +1,17 @@ +<% navigation(title: "Workers", section: :workers) %> + + + + + + + + + + + + + <%= render partial: "mission_control/jobs/workers/worker", collection: @workers %> + + +
WorkerHostnameJobsLast heartbeat
diff --git a/app/views/mission_control/jobs/workers/show.html.erb b/app/views/mission_control/jobs/workers/show.html.erb new file mode 100644 index 00000000..18e9c139 --- /dev/null +++ b/app/views/mission_control/jobs/workers/show.html.erb @@ -0,0 +1,7 @@ +<% navigation(title: "Worker #{@worker.id}", section: :workers) %> + +<%= render "mission_control/jobs/workers/title", worker: @worker %> +<%= render "mission_control/jobs/workers/configuration", worker: @worker %> +<%= render "mission_control/jobs/workers/jobs", worker: @worker %> +<%= render "mission_control/jobs/workers/raw_data", worker: @worker %> + diff --git a/bin/setup b/bin/setup index f2bf5a66..b3e163ab 100755 --- a/bin/setup +++ b/bin/setup @@ -9,4 +9,4 @@ bundle echo "Creating databases..." -rails db:setup +rails db:reset diff --git a/config/routes.rb b/config/routes.rb index 6f6054cb..0a04ba44 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,30 +1,33 @@ MissionControl::Jobs::Engine.routes.draw do - resources :applications do - resources :queues do + resources :applications, only: [] do + resources :queues, only: [ :index, :show ] do scope module: :queues do - resource :status, controller: "status", only: [] do - put "pause", "resume", on: :member - end - - resources :jobs + resource :pause, only: [ :create, :destroy ] end end - resources :failed_jobs do - scope module: :failed_jobs do - resource :retry, only: :create - resource :discard, only: :create + resources :jobs, only: :show do + resource :retry, only: :create + resource :discard, only: :create + + collection do + resource :bulk_retries, only: :create + resource :bulk_discards, only: :create end end - namespace :failed_jobs do - resource :bulk_retry, only: :create - resource :bulk_discard, only: :create - end + resources :jobs, only: :index, path: ":status/jobs" + + resources :workers, only: [ :index, :show ] end - # Allow referencing resources urls without providing an application_id. It will default to the first one. - resources :queues, :failed_jobs + # Allow referencing urls without providing an application_id. It will default to the first one. + resources :queues, only: [ :index, :show ] + + resources :jobs, only: :show + resources :jobs, only: :index, path: ":status/jobs" + + resources :workers, only: [ :index, :show ] root to: "queues#index" end diff --git a/lib/active_job/errors/job_not_found_error.rb b/lib/active_job/errors/job_not_found_error.rb index 62c61348..a1241691 100644 --- a/lib/active_job/errors/job_not_found_error.rb +++ b/lib/active_job/errors/job_not_found_error.rb @@ -1,7 +1,11 @@ module ActiveJob module Errors class JobNotFoundError < StandardError - def initialize(job_or_job_id) + attr_reader :job_relation + + def initialize(job_or_job_id, job_relation) + @job_relation = job_relation + job_id = job_or_job_id.is_a?(ActiveJob::Base) ? job_or_job_id.job_id : job_or_job_id super "Job with id '#{job_id}' not found" end diff --git a/lib/active_job/executing.rb b/lib/active_job/executing.rb index ce67a091..e27ab15f 100644 --- a/lib/active_job/executing.rb +++ b/lib/active_job/executing.rb @@ -1,11 +1,13 @@ -# TODO: These should be moved to +ActiveJob::Core+ and related concerns -# when upstreamed. +# TODO: These (or a version of them) should be moved to +ActiveJob::Core+ +# and related concerns when upstreamed. module ActiveJob::Executing extend ActiveSupport::Concern included do - attr_accessor :raw_data, :position + attr_accessor :raw_data, :position, :finished_at, :blocked_by, :blocked_until, :worker_id, :started_at attr_reader :serialized_arguments + attr_writer :status + thread_cattr_accessor :current_queue_adapter end @@ -20,15 +22,22 @@ def retry end def discard - jobs_relation.discard_job(self) + jobs_relation_for_discarding.discard_job(self) + end + + def status + return @status if @status.present? + + failed? ? :failed : :pending end private - def jobs_relation - if failed? - ActiveJob.jobs.failed + def jobs_relation_for_discarding + case status + when :failed then ActiveJob.jobs.failed + when :pending then ActiveJob.jobs.pending.where(queue_name: queue_name) else - ActiveJob.jobs.where(queue: queue_name) + ActiveJob.jobs end end end diff --git a/lib/active_job/job_proxy.rb b/lib/active_job/job_proxy.rb index 0ad0de0a..7441db32 100644 --- a/lib/active_job/job_proxy.rb +++ b/lib/active_job/job_proxy.rb @@ -6,17 +6,17 @@ class ActiveJob::JobProxy < ActiveJob::Base class UnsupportedError < StandardError; end - attr_reader :class_name + attr_reader :job_class_name def initialize(job_data) super - @class_name = job_data["job_class"] + @job_class_name = job_data["job_class"] deserialize(job_data) end def serialize super.tap do |json| - json["job_class"] = @class_name + json["job_class"] = @job_class_name end end diff --git a/lib/active_job/jobs_relation.rb b/lib/active_job/jobs_relation.rb index a4bb95a7..09a78ffe 100644 --- a/lib/active_job/jobs_relation.rb +++ b/lib/active_job/jobs_relation.rb @@ -7,7 +7,7 @@ # example: # # queue = ActiveJob::Base.queues[:default] -# queue.jobs.limit(10).where(job_class: "DummyJob").last +# queue.jobs.limit(10).where(job_class_name: "DummyJob").last # # Relations are enumerable, so you can use +Enumerable+ methods on them. # Notice however that using these methods will imply loading all the relation @@ -22,9 +22,10 @@ class ActiveJob::JobsRelation include Enumerable - STATUSES = %i[ pending failed ] + STATUSES = %i[ pending failed in_progress blocked scheduled finished ] + FILTERS = %i[ queue_name job_class_name ] - PROPERTIES = %i[ queue_name status offset_value limit_value job_class_name ] + PROPERTIES = %i[ queue_name status offset_value limit_value job_class_name worker_id ] attr_reader *PROPERTIES, :default_page_size delegate :last, :[], :reverse, to: :to_a @@ -43,21 +44,21 @@ def initialize(queue_adapter: ActiveJob::Base.queue_adapter, default_page_size: # # === Options # - # * :job_class - To only include the jobs of a given class. + # * :job_class_name - To only include the jobs of a given class. # Depending on the configured queue adapter, this will perform the # filtering in memory, which could introduce performance concerns # for large sets of jobs. - # * :queue - To only include the jobs in the provided queue. - def where(job_class: nil, queue: nil) + # * :queue_name - To only include the jobs in the provided queue. + # * :worker_id - To only include the jobs processed by the provided worker. + def where(job_class_name: nil, queue_name: nil, worker_id: nil) # Remove nil arguments to avoid overriding parameters when concatenating +where+ clauses - arguments = { job_class_name: job_class, queue_name: queue }.compact.collect { |key, value| [ key, value.to_s ] }.to_h + arguments = { job_class_name: job_class_name, queue_name: queue_name, worker_id: worker_id }.compact.collect { |key, value| [ key, value.to_s ] }.to_h clone_with **arguments end - # This allows to unset a previous +job_class+ set in the relation. - def with_all_job_classes - if job_class_name.present? - clone_with job_class_name: nil + def with_status(status) + if status.to_sym.in? STATUSES + clone_with status: status.to_sym else self end @@ -65,7 +66,7 @@ def with_all_job_classes STATUSES.each do |status| define_method status do - clone_with status: status + with_status(status) end define_method "#{status}?" do @@ -85,8 +86,8 @@ def limit(limit) # Returns the number of jobs in the relation. # - # When filtering jobs by class name, if the adapter doesn't support - # it directly, this will imply loading all the jobs in memory. + # When filtering jobs, if the adapter doesn't support the filter(s) + # directly, this will load all the jobs in memory to filter them. def count if loaded? || filtering_needed? to_a.length @@ -135,7 +136,7 @@ def retry_job(job) queue_adapter.retry_job(job, self) end - # Discard all the jobs in the queue. + # Discard all the jobs in the relation. def discard_all queue_adapter.discard_all_jobs(self) nil @@ -157,17 +158,18 @@ def find_by_id(job_id) # # Raises +ActiveJob::Errors::JobNotFoundError+ when not found. def find_by_id!(job_id) - queue_adapter.find_job(job_id, self) or raise ActiveJob::Errors::JobNotFoundError.new(job_id) + queue_adapter.find_job(job_id, self) or raise ActiveJob::Errors::JobNotFoundError.new(job_id, self) end - # Returns an array of jobs classes in the first +from_first+ jobs. - def job_classes(from_first: 500) - first(from_first).collect(&:class_name).uniq + # Returns an array of jobs class names in the first +from_first+ jobs. + def job_class_names(from_first: 500) + first(from_first).collect(&:job_class_name).uniq end def reload @count = nil @loaded_jobs = nil + @filters = nil self end @@ -193,6 +195,10 @@ def limit_value_provided? limit_value.present? && limit_value != ActiveJob::JobsRelation::ALL_JOBS_LIMIT end + def filtering_needed? + filters.any? + end + private attr_reader :queue_adapter, :loaded_jobs attr_writer *PROPERTIES @@ -200,7 +206,6 @@ def limit_value_provided? def set_defaults self.offset_value = 0 self.limit_value = ALL_JOBS_LIMIT - self.status = :pending end def clone_with(**properties) @@ -243,18 +248,17 @@ def loaded? !@loaded_jobs.nil? end + # Filtering for not natively supported filters is performed in memory def filter(jobs) jobs.filter { |job| satisfy_filter?(job) } end - # If adapter does not support filtering by class name, it will perform - # the filtering in memory. - def filtering_needed? - job_class_name.present? && !queue_adapter.support_class_name_filtering? + def satisfy_filter?(job) + filters.all? { |property| public_send(property) == job.public_send(property) } end - def satisfy_filter?(job) - job.class_name == job_class_name + def filters + @filters ||= FILTERS.select { |property| public_send(property).present? && !queue_adapter.supports_filter?(self, property) } end def ensure_failed_status diff --git a/lib/active_job/querying.rb b/lib/active_job/querying.rb index 25d8daaf..375d4bc4 100644 --- a/lib/active_job/querying.rb +++ b/lib/active_job/querying.rb @@ -23,7 +23,7 @@ def jobs def fetch_queues queue_adapter.queues.collect do |queue| ActiveJob::Queue.new(queue[:name], size: queue[:size], active: queue[:active], queue_adapter: queue_adapter) - end.compact + end end end diff --git a/lib/active_job/queue.rb b/lib/active_job/queue.rb index e24ef078..0a800574 100644 --- a/lib/active_job/queue.rb +++ b/lib/active_job/queue.rb @@ -41,9 +41,9 @@ def active? @active = !queue_adapter.queue_paused?(name) end - # Return an +ActiveJob::JobsRelation+ with the jobs in the queue. + # Return an +ActiveJob::JobsRelation+ with the pending jobs in the queue. def jobs - ActiveJob::JobsRelation.new(queue_adapter: queue_adapter).where(queue: name) + ActiveJob::JobsRelation.new(queue_adapter: queue_adapter).pending.where(queue_name: name) end def reload diff --git a/lib/active_job/queue_adapters/resque_ext.rb b/lib/active_job/queue_adapters/resque_ext.rb index abc60bd3..b3c57c18 100644 --- a/lib/active_job/queue_adapters/resque_ext.rb +++ b/lib/active_job/queue_adapters/resque_ext.rb @@ -1,4 +1,6 @@ module ActiveJob::QueueAdapters::ResqueExt + include MissionControl::Jobs::Adapter + def initialize(redis = Resque.redis) super() @redis = redis @@ -8,17 +10,6 @@ def activating(&block) Resque.with_per_thread_redis_override(redis, &block) end - def queue_names - Resque.queues - end - - # Returns an array with the list of queues. Each queue is represented as a hash - # with these attributes: - # { - # "name": "queue_name", - # "size": 1, - # active: true - # } def queues queues = queue_names active_statuses = [] @@ -56,6 +47,12 @@ def queue_paused?(queue_name) ResquePauseHelper.paused?(queue_name) end + def supported_filters(jobs_relation) + if jobs_relation.pending? then [ :queue_name ] + else [] + end + end + def jobs_count(jobs_relation) resque_jobs_for(jobs_relation).count end @@ -64,10 +61,6 @@ def fetch_jobs(jobs_relation) resque_jobs_for(jobs_relation).all end - def support_class_name_filtering? - false - end - def retry_all_jobs(jobs_relation) resque_jobs_for(jobs_relation).retry_all end @@ -91,6 +84,10 @@ def find_job(job_id, jobs_relation) private attr_reader :redis + def queue_names + Resque.queues + end + def resque_jobs_for(jobs_relation) ResqueJobs.new(jobs_relation, redis: redis) end @@ -98,7 +95,7 @@ def resque_jobs_for(jobs_relation) class ResqueJobs attr_reader :jobs_relation - delegate :default_page_size, to: :jobs_relation + delegate :default_page_size, :paginated?, :limit_value_provided?, to: :jobs_relation def initialize(jobs_relation, redis:) @jobs_relation = jobs_relation @@ -161,19 +158,11 @@ def find_job(job_id) MAX_REDIS_TRANSACTION_SIZE = 100 def targeting_all_jobs? - !paginated? && jobs_relation.job_class_name.blank? - end - - def paginated? - jobs_relation.offset_value > 0 || limit_value_provided? - end - - def limit_value_provided? - jobs_relation.limit_value.present? && jobs_relation.limit_value != ActiveJob::JobsRelation::ALL_JOBS_LIMIT + !paginated? && !jobs_relation.filtering_needed? end def fetch_resque_jobs - if jobs_relation.failed? + if jobs_relation.failed? || jobs_relation.queue_name.blank? fetch_failed_resque_jobs else fetch_queue_resque_jobs @@ -198,6 +187,7 @@ def deserialize_resque_job(resque_job_hash, index) job.raw_data = resque_job_hash job.position = jobs_relation.offset_value + index job.failed_at = resque_job_hash["failed_at"]&.to_datetime + job.status = job.failed_at.present? ? :failed : :pending end end @@ -211,14 +201,7 @@ def execution_error_from_resque_job(resque_job_hash) end def direct_jobs_count - case jobs_relation.status - when :pending - pending_jobs_count - when :failed - failed_jobs_count - else - raise ActiveJob::Errors::QueryError, "Status not supported: #{status}" - end + jobs_relation.failed? ? failed_jobs_count : pending_jobs_count end def pending_jobs_count @@ -306,13 +289,9 @@ def jobs_by_id @jobs_by_id ||= all.index_by(&:job_id) end - def all_ignoring_filters - @all_ignoring_filters ||= jobs_relation.with_all_job_classes.to_a - end - def handle_resque_job_error(job, error) if error.message =~/no such key/i - raise ActiveJob::Errors::JobNotFoundError.new(job) + raise ActiveJob::Errors::JobNotFoundError.new(job, jobs_relation) else raise error end diff --git a/lib/active_job/queue_adapters/solid_queue_ext.rb b/lib/active_job/queue_adapters/solid_queue_ext.rb index 8bb93053..d15afbf6 100644 --- a/lib/active_job/queue_adapters/solid_queue_ext.rb +++ b/lib/active_job/queue_adapters/solid_queue_ext.rb @@ -1,25 +1,15 @@ module ActiveJob::QueueAdapters::SolidQueueExt - def activating(&block) - block.call - end - - def queue_names - SolidQueue::Queue.all.map(&:name) - end + include MissionControl::Jobs::Adapter - # Returns an array with the list of queues. Each queue is represented as a hash - # with these attributes: - # { - # "name": "queue_name", - # "size": 1, - # active: true - # } def queues - SolidQueue::Queue.all.collect do |queue| + queues = SolidQueue::Queue.all + pauses = SolidQueue::Pause.where(queue_name: queues.map(&:name)).index_by(&:queue_name) + + queues.collect do |queue| { name: queue.name, size: queue.size, - active: !queue.paused? + active: pauses[queue.name].nil? } end end @@ -44,20 +34,40 @@ def queue_paused?(queue_name) find_queue_by_name(queue_name).paused? end - def jobs_count(jobs_relation) - find_solid_queue_jobs_within(jobs_relation).count + def supported_statuses + RelationAdapter::STATUS_MAP.keys end - def fetch_jobs(jobs_relation) - find_solid_queue_jobs_within(jobs_relation).map { |job| deserialize_and_proxy_job(job) } + def supported_filters(*) + [ :queue_name, :job_class_name ] end - def support_class_name_filtering? + def exposes_workers? true end + def workers + SolidQueue::Process.where(kind: "Worker").collect do |process| + worker_attributes_from_solid_queue_process(process) + end + end + + def find_worker(worker_id) + if process = SolidQueue::Process.find_by(id: worker_id) + worker_attributes_from_solid_queue_process(process) + end + end + + def jobs_count(jobs_relation) + RelationAdapter.new(jobs_relation).count + end + + def fetch_jobs(jobs_relation) + find_solid_queue_jobs_within(jobs_relation).map { |job| deserialize_and_proxy_solid_queue_job(job, jobs_relation.status) } + end + def retry_all_jobs(jobs_relation) - find_solid_queue_jobs_within(jobs_relation).each(&:retry) + RelationAdapter.new(jobs_relation).retry_all end def retry_job(job, jobs_relation) @@ -65,16 +75,16 @@ def retry_job(job, jobs_relation) end def discard_all_jobs(jobs_relation) - find_solid_queue_jobs_within(jobs_relation).each(&:discard) + RelationAdapter.new(jobs_relation).discard_all end def discard_job(job, jobs_relation) find_solid_queue_job!(job.job_id, jobs_relation).discard end - def find_job(job_id, jobs_relation) - if job = find_solid_queue_job(job_id, jobs_relation) - deserialize_and_proxy_job job + def find_job(job_id, *) + if job = SolidQueue::Job.find_by(active_job_id: job_id) + deserialize_and_proxy_solid_queue_job job end end @@ -83,27 +93,50 @@ def find_queue_by_name(queue_name) SolidQueue::Queue.find_by_name(queue_name) end + def worker_attributes_from_solid_queue_process(process) + { + id: process.id, + name: "PID: #{process.pid}", + hostname: process.hostname, + last_heartbeat_at: process.last_heartbeat_at, + configuration: process.metadata, + raw_data: process.as_json + } + end + def find_solid_queue_job!(job_id, jobs_relation) - find_solid_queue_job(job_id, jobs_relation) or raise ActiveJob::Errors::JobNotFoundError.new(job_id) + find_solid_queue_job(job_id, jobs_relation) or raise ActiveJob::Errors::JobNotFoundError.new(job_id, jobs_relation) end def find_solid_queue_job(job_id, jobs_relation) - find_solid_queue_jobs_within(jobs_relation).find_by(active_job_id: job_id) + RelationAdapter.new(jobs_relation).find_job(job_id) end def find_solid_queue_jobs_within(jobs_relation) - JobFilter.new(jobs_relation).jobs + RelationAdapter.new(jobs_relation).jobs end - def deserialize_and_proxy_job(solid_queue_job) + def deserialize_and_proxy_solid_queue_job(solid_queue_job, job_status = nil) + job_status ||= status_from_solid_queue_job(solid_queue_job) + ActiveJob::JobProxy.new(solid_queue_job.arguments).tap do |job| - job.last_execution_error = execution_error_from_job(solid_queue_job) + job.status = job_status + job.last_execution_error = execution_error_from_solid_queue_job(solid_queue_job) if job_status == :failed job.raw_data = solid_queue_job.as_json - job.failed_at = solid_queue_job.failed_execution&.created_at + job.failed_at = solid_queue_job&.failed_execution&.created_at if job_status == :failed + job.finished_at = solid_queue_job.finished_at + job.blocked_by = solid_queue_job.concurrency_key + job.blocked_until = solid_queue_job&.blocked_execution&.expires_at if job_status == :blocked + job.worker_id = solid_queue_job&.claimed_execution&.process_id if job_status == :in_progress + job.started_at = solid_queue_job&.claimed_execution&.created_at if job_status == :in_progress end end - def execution_error_from_job(solid_queue_job) + def status_from_solid_queue_job(solid_queue_job) + RelationAdapter::STATUS_MAP.invert[solid_queue_job.status] + end + + def execution_error_from_solid_queue_job(solid_queue_job) if solid_queue_job.failed? ActiveJob::ExecutionError.new \ error_class: solid_queue_job.failed_execution.exception_class, @@ -112,46 +145,137 @@ def execution_error_from_job(solid_queue_job) end end - class JobFilter + class RelationAdapter + STATUS_MAP = { + pending: :ready, + failed: :failed, + in_progress: :claimed, + blocked: :blocked, + scheduled: :scheduled, + finished: :finished + } + def initialize(jobs_relation) @jobs_relation = jobs_relation end def jobs - filter_by_status - .then { |jobs| filter_by_queue(jobs) } - .then { |jobs| filter_by_class(jobs) } - .then { |jobs| limit(jobs) } - .then { |jobs| offset(jobs) } + solid_queue_status.finished? ? finished_jobs.order(finished_at: :desc) : executions.order(:job_id).map(&:job) + end + + def count + limit_value_provided? ? direct_count : internally_limited_count + end + + def find_job(active_job_id) + if job = SolidQueue::Job.find_by(active_job_id: active_job_id) + job if matches_relation_filters?(job) + end + end + + def discard_all + execution_class_by_status.discard_all_from_jobs(jobs) + end + + def retry_all + SolidQueue::FailedExecution.retry_all(jobs) end private attr_reader :jobs_relation - delegate :queue_name, :status, :limit_value, :offset_value, :job_class_name, to: :jobs_relation + delegate :queue_name, :limit_value, :limit_value_provided?, :offset_value, :job_class_name, :default_page_size, :worker_id, to: :jobs_relation + + def executions + execution_class_by_status.includes(job: "#{solid_queue_status}_execution") + .then { |executions| filter_executions_by_queue(executions) } + .then { |executions| filter_executions_by_class(executions) } + .then { |executions| filter_executions_by_process_id(executions) } + .then { |executions| limit(executions) } + .then { |executions| offset(executions) } + end + + def finished_jobs + SolidQueue::Job.finished + .then { |jobs| filter_jobs_by_queue(jobs) } + .then { |jobs| filter_jobs_by_class(jobs) } + .then { |jobs| limit(jobs) } + .then { |jobs| offset(jobs) } + end + + def matches_relation_filters?(job) + matches_status?(job) && matches_queue_name?(job) + end + + def direct_count + solid_queue_status.finished? ? finished_jobs.count : executions.count + end + + INTERNAL_COUNT_LIMIT = 500_000 # Hard limit to keep unlimited count queries fast enough + + def internally_limited_count + limited_count = solid_queue_status.finished? ? finished_jobs.limit(INTERNAL_COUNT_LIMIT + 1).count : executions.limit(INTERNAL_COUNT_LIMIT + 1).count + (limited_count == INTERNAL_COUNT_LIMIT + 1) ? Float::INFINITY : limited_count + end - def filter_by_status - case status - when :pending then SolidQueue::Job.joins(:ready_execution) - when :failed then SolidQueue::Job.joins(:failed_execution) - else SolidQueue::Job.all + def execution_class_by_status + if solid_queue_status.present? && !solid_queue_status.finished? + "SolidQueue::#{solid_queue_status.capitalize}Execution".safe_constantize + else + raise ActiveJob::Errors::QueryError, "Status not supported: #{jobs_relation.status}" end end - def filter_by_queue(jobs) + def filter_executions_by_queue(executions) + return executions unless queue_name.present? + + if solid_queue_status.ready? + executions.where(queue_name: queue_name) + else + executions.where(job: { queue_name: queue_name }) + end + end + + def filter_jobs_by_queue(jobs) queue_name.present? ? jobs.where(queue_name: queue_name) : jobs end - def filter_by_class(jobs) + def filter_executions_by_class(executions) + job_class_name.present? ? executions.where(job: { class_name: job_class_name }) : executions + end + + def filter_executions_by_process_id(executions) + return executions unless worker_id.present? + + if solid_queue_status.claimed? + executions.where(process_id: worker_id) + else + raise ActiveJob::Errors::QueryError, "Filtering by worker ID is not supported for status #{jobs_relation.status}" + end + end + + def filter_jobs_by_class(jobs) job_class_name.present? ? jobs.where(class_name: job_class_name) : jobs end - def limit(jobs) - limit_value.present? ? jobs.limit(limit_value) : jobs + def limit(executions_or_jobs) + limit_value.present? ? executions_or_jobs.limit(limit_value) : executions_or_jobs + end + + def offset(executions_or_jobs) + offset_value.present? ? executions_or_jobs.offset(offset_value) : executions_or_jobs + end + + def matches_status?(job) + solid_queue_status.blank? || job.public_send("#{solid_queue_status}?") + end + + def matches_queue_name?(job) + queue_name.blank? || job.queue_name == queue_name end - def offset(jobs) - offset_value.present? ? jobs.offset(offset_value) : jobs + def solid_queue_status + STATUS_MAP[jobs_relation.status].to_s.inquiry end end end diff --git a/lib/mission_control/jobs/adapter.rb b/lib/mission_control/jobs/adapter.rb new file mode 100644 index 00000000..b5fd8a5d --- /dev/null +++ b/lib/mission_control/jobs/adapter.rb @@ -0,0 +1,108 @@ +module MissionControl::Jobs::Adapter + def activating(&block) + block.call + end + + def supported_statuses + # All adapters need to support these at a minimum + [ :pending, :failed ] + end + + def supports_filter?(jobs_relation, filter) + supported_filters(jobs_relation).include?(filter) + end + + # List of filters supported natively. Non-supported filters are done in memory. + def supported_filters(jobs_relation) + [] + end + + def supports_queue_pausing? + true + end + + def exposes_workers? + false + end + + # Returns an array with the list of workers. Each worker is represented as a hash + # with these attributes: + # { + # id: 123, + # name: "adapter-name", + # hostname: "hey-default-101", + # last_heartbeat_at: Fri, 26 Jan 2024 20:31:09.652174000 UTC +00:00, + # configuration: { ... } + # raw_data: { ... } + # } + def workers + if exposes_workers? + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `workers`") + end + end + + # Returns an array with the list of queues. Each queue is represented as a hash + # with these attributes: + # { + # name: "queue_name", + # size: 1, + # active: true + # } + def queues + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `queue_names`") + end + + def queue_size(queue_name) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `queue_size`") + end + + def clear_queue(queue_name) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `clear_queue`") + end + + def pause_queue(queue_name) + if supports_queue_pausing? + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `pause_queue`") + end + end + + def resume_queue(queue_name) + if supports_queue_pausing? + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `resume_queue`") + end + end + + def queue_paused?(queue_name) + if supports_queue_pausing? + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `queue_paused?`") + end + end + + def jobs_count(jobs_relation) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `jobs_count`") + end + + def fetch_jobs(jobs_relation) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `fetch_jobs`") + end + + def retry_all_jobs(jobs_relation) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `retry_all_jobs`") + end + + def retry_job(job, jobs_relation) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `retry_job`") + end + + def discard_all_jobs(jobs_relation) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `discard_all_jobs`") + end + + def discard_job(job, jobs_relation) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `discard_job`") + end + + def find_job(job_id, *) + raise MissionControl::Jobs::Errors::IncompatibleAdapter("Adapter must implement `find_job`") + end +end diff --git a/lib/mission_control/jobs/engine.rb b/lib/mission_control/jobs/engine.rb index 73150f8c..2802dc1d 100644 --- a/lib/mission_control/jobs/engine.rb +++ b/lib/mission_control/jobs/engine.rb @@ -63,6 +63,8 @@ class Engine < ::Rails::Engine end console do + require "irb/context" + IRB::Context.prepend(MissionControl::Jobs::Console::Context) Rails::ConsoleMethods.include(MissionControl::Jobs::Console::Helpers) diff --git a/lib/mission_control/jobs/errors/incompatible_adapter.rb b/lib/mission_control/jobs/errors/incompatible_adapter.rb new file mode 100644 index 00000000..051d9d93 --- /dev/null +++ b/lib/mission_control/jobs/errors/incompatible_adapter.rb @@ -0,0 +1,2 @@ +class MissionControl::Jobs::Errors::IncompatibleAdapter < StandardError +end diff --git a/lib/mission_control/jobs/server.rb b/lib/mission_control/jobs/server.rb index ead97d01..e36e474b 100644 --- a/lib/mission_control/jobs/server.rb +++ b/lib/mission_control/jobs/server.rb @@ -1,5 +1,8 @@ +require "active_job/queue_adapter" + class MissionControl::Jobs::Server - include MissionControl::Jobs::IdentifiedByName, Serializable + include MissionControl::Jobs::IdentifiedByName + include Serializable, Workers attr_reader :name, :queue_adapter, :application @@ -16,4 +19,8 @@ def activating(&block) ensure ActiveJob::Base.current_queue_adapter = previous_adapter end + + def queue_adapter_name + ActiveJob.adapter_name(queue_adapter).underscore.to_sym + end end diff --git a/lib/mission_control/jobs/server/serializable.rb b/lib/mission_control/jobs/server/serializable.rb index 73e6b2c3..48b90ee2 100644 --- a/lib/mission_control/jobs/server/serializable.rb +++ b/lib/mission_control/jobs/server/serializable.rb @@ -4,7 +4,7 @@ module MissionControl::Jobs::Server::Serializable class_methods do # Loads a server from a locator string with the format +:+. For example: # - # bc3:chicago + # bc4:resque_chicago # # When the ++ fragment is omitted it will return the first server for the application. def from_global_id(global_id) diff --git a/lib/mission_control/jobs/server/workers.rb b/lib/mission_control/jobs/server/workers.rb new file mode 100644 index 00000000..d664a8b8 --- /dev/null +++ b/lib/mission_control/jobs/server/workers.rb @@ -0,0 +1,15 @@ +module MissionControl::Jobs::Server::Workers + def workers + queue_adapter.workers.collect do |worker| + MissionControl::Jobs::Worker.new(queue_adapter: queue_adapter, **worker) + end + end + + def find_worker(worker_id) + if worker = queue_adapter.find_worker(worker_id) + MissionControl::Jobs::Worker.new(queue_adapter: queue_adapter, **worker) + else + raise MissionControl::Jobs::Errors::ResourceNotFound, "No worker found with ID #{worker_id}" + end + end +end diff --git a/mission_control-jobs.gemspec b/mission_control-jobs.gemspec index 4459f593..784060c3 100644 --- a/mission_control-jobs.gemspec +++ b/mission_control-jobs.gemspec @@ -20,20 +20,20 @@ Gem::Specification.new do |spec| Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] end - spec.add_dependency "rails", ">= 7.0.3.1" + spec.add_dependency "rails", "~> 7.1" spec.add_dependency 'importmap-rails' spec.add_dependency 'turbo-rails' spec.add_dependency 'stimulus-rails' spec.add_development_dependency "resque" spec.add_development_dependency "solid_queue" - spec.add_development_dependency "capybara" spec.add_development_dependency "selenium-webdriver" spec.add_development_dependency "resque-pause" spec.add_development_dependency "mocha" + spec.add_development_dependency "debug" spec.add_development_dependency "redis", "~> 4.0.0" spec.add_development_dependency "redis-namespace" - spec.add_development_dependency "rubocop" + spec.add_development_dependency "rubocop", "~> 1.52.0" spec.add_development_dependency "rubocop-performance" spec.add_development_dependency "rubocop-rails" end diff --git a/test/active_job/jobs_relation_test.rb b/test/active_job/jobs_relation_test.rb index 786dfc58..e6ef3a98 100644 --- a/test/active_job/jobs_relation_test.rb +++ b/test/active_job/jobs_relation_test.rb @@ -7,17 +7,16 @@ class ActiveJob::JobsRelationTest < ActiveSupport::TestCase test "pass job class names" do assert_nil @jobs.job_class_name - assert "SomeJob", @jobs.where(job_class: "SomeJob").job_class_name + assert "SomeJob", @jobs.where(job_class_name: "SomeJob").job_class_name end test "filter by pending status" do - assert @jobs.pending? assert @jobs.pending.pending? assert_not @jobs.failed.pending? end test "filter by failed status" do - assert_not @jobs.failed? + assert_not @jobs.pending.failed? assert @jobs.failed.failed? end @@ -30,23 +29,20 @@ class ActiveJob::JobsRelationTest < ActiveSupport::TestCase end test "set job class and queue" do - jobs = @jobs.where(job_class: "MyJob") + jobs = @jobs.where(job_class_name: "MyJob") assert_equal "MyJob", jobs.job_class_name # Supports concatenation without overriding exising properties - jobs = jobs.where(queue: "my_queue") + jobs = jobs.where(queue_name: "my_queue") assert_equal "my_queue", jobs.queue_name assert_equal "MyJob", jobs.job_class_name end - test "allow removing the job class previously set" do - jobs = @jobs.where(job_class: "MyJob").with_all_job_classes - assert_nil jobs.job_class_name - end - test "caches the fetched set of jobs" do ActiveJob::Base.queue_adapter.expects(:fetch_jobs).twice.returns([ :job_1, :job_2 ], []) - jobs = @jobs.where(queue: "my_queue") + ActiveJob::Base.queue_adapter.expects(:supports_filter?).at_least_once.returns(true) + + jobs = @jobs.where(queue_name: "my_queue") 5.times do assert_equal [ :job_1, :job_2 ], jobs.to_a @@ -55,8 +51,9 @@ class ActiveJob::JobsRelationTest < ActiveSupport::TestCase test "caches the count of jobs" do ActiveJob::Base.queue_adapter.expects(:jobs_count).once.returns(2) + ActiveJob::Base.queue_adapter.expects(:supports_filter?).at_least_once.returns(true) - jobs = @jobs.where(queue: "my_queue") + jobs = @jobs.where(queue_name: "my_queue") 3.times do assert_equal 2, jobs.count diff --git a/test/active_job/queue_adapters/adapter_testing/count_jobs.rb b/test/active_job/queue_adapters/adapter_testing/count_jobs.rb index 77033d61..d940690c 100644 --- a/test/active_job/queue_adapters/adapter_testing/count_jobs.rb +++ b/test/active_job/queue_adapters/adapter_testing/count_jobs.rb @@ -22,10 +22,8 @@ module ActiveJob::QueueAdapters::AdapterTesting::CountJobs 5.times { DummyJob.perform_later } 10.times { DummyReloadedJob.perform_later } - queue = ApplicationJob.queues[:default] - - assert_equal 5, queue.jobs.where(job_class: "DummyJob").count - assert_equal 10, queue.jobs.where(job_class: "DummyReloadedJob").count + assert_equal 5, ApplicationJob.jobs.pending.where(queue_name: "default", job_class_name: "DummyJob").count + assert_equal 10, ApplicationJob.jobs.pending.where(queue_name: "default", job_class_name: "DummyReloadedJob").count end test "count the pending jobs in a given queue" do @@ -35,9 +33,13 @@ module ActiveJob::QueueAdapters::AdapterTesting::CountJobs 3.times { DummyJob.perform_later } assert_equal 8, ApplicationJob.queues.sum(&:size) - assert_equal 5, ApplicationJob.jobs.where(queue: "default").count - assert_equal 3, ApplicationJob.jobs.where(queue: "other_queue").count - assert_equal 3, ApplicationJob.jobs.where(queue: :other_queue).count + assert_equal 5, ApplicationJob.jobs.pending.where(queue_name: "default").count + assert_equal 3, ApplicationJob.jobs.pending.where(queue_name: "other_queue").count + assert_equal 3, ApplicationJob.jobs.pending.where(queue_name: :other_queue).count + + assert_equal 5, ApplicationJob.queues[:default].size + assert_equal 3, ApplicationJob.queues[:other_queue].size + assert_equal 3, ApplicationJob.queues["other_queue"].size end test "check if there are failed jobs" do @@ -64,8 +66,21 @@ module ActiveJob::QueueAdapters::AdapterTesting::CountJobs perform_enqueued_jobs - assert 5, ApplicationJob.jobs.failed.where(job_class: "FailingJob").count - assert 10, ApplicationJob.jobs.failed.where(job_class: "FailingReloadedJob").count + assert 5, ApplicationJob.jobs.failed.where(job_class_name: "FailingJob").count + assert 10, ApplicationJob.jobs.failed.where(job_class_name: "FailingReloadedJob").count + end + + test "count failed jobs of a given queue" do + FailingJob.queue_as :queue_1 + FailingReloadedJob.queue_as :queue_2 + + 5.times { FailingJob.perform_later } + 10.times { FailingReloadedJob.perform_later } + + perform_enqueued_jobs + + assert 5, ApplicationJob.jobs.failed.where(queue_name: :queue_1).count + assert 10, ApplicationJob.jobs.failed.where(queue_name: :queue_2).count end test "count failing jobs with offset and limit" do @@ -87,9 +102,9 @@ module ActiveJob::QueueAdapters::AdapterTesting::CountJobs 10.times { FailingReloadedJob.perform_later } perform_enqueued_jobs - assert_equal 7, ApplicationJob.jobs.failed.where(job_class: "FailingJob").offset(3).count - assert_equal 2, ApplicationJob.jobs.failed.where(job_class: "FailingJob").limit(2).count - assert_equal 2, ApplicationJob.jobs.failed.where(job_class: "FailingJob").offset(3).limit(2).count - assert_equal 3, ApplicationJob.jobs.failed.where(job_class: "FailingJob").offset(7).limit(10).count + assert_equal 7, ApplicationJob.jobs.failed.where(job_class_name: "FailingJob").offset(3).count + assert_equal 2, ApplicationJob.jobs.failed.where(job_class_name: "FailingJob").limit(2).count + assert_equal 2, ApplicationJob.jobs.failed.where(job_class_name: "FailingJob").offset(3).limit(2).count + assert_equal 3, ApplicationJob.jobs.failed.where(job_class_name: "FailingJob").offset(7).limit(10).count end end diff --git a/test/active_job/queue_adapters/adapter_testing/discard_jobs.rb b/test/active_job/queue_adapters/adapter_testing/discard_jobs.rb index 967ecb79..64e523f6 100644 --- a/test/active_job/queue_adapters/adapter_testing/discard_jobs.rb +++ b/test/active_job/queue_adapters/adapter_testing/discard_jobs.rb @@ -47,10 +47,24 @@ module ActiveJob::QueueAdapters::AdapterTesting::DiscardJobs 10.times { FailingReloadedJob.perform_later } perform_enqueued_jobs - ActiveJob.jobs.failed.where(job_class: "FailingJob").discard_all + ActiveJob.jobs.failed.where(job_class_name: "FailingJob").discard_all - assert_empty ApplicationJob.jobs.failed.where(job_class: "FailingJob") - assert_equal 10, ApplicationJob.jobs.failed.where(job_class: "FailingReloadedJob").count + assert_empty ActiveJob.jobs.failed.where(job_class_name: "FailingJob") + assert_equal 10, ActiveJob.jobs.failed.where(job_class_name: "FailingReloadedJob").count + end + + test "discard only failed of a given queue" do + FailingJob.queue_as :queue_1 + FailingReloadedJob.queue_as :queue_2 + + 5.times { FailingJob.perform_later } + 10.times { FailingReloadedJob.perform_later } + perform_enqueued_jobs +$debug = true + ActiveJob.jobs.failed.where(queue_name: :queue_1).discard_all + + assert_empty ActiveJob.jobs.failed.where(job_class_name: "FailingJob") + assert_equal 10, ActiveJob.jobs.failed.where(job_class_name: "FailingReloadedJob").count end test "discard all pending withing a given page" do diff --git a/test/active_job/queue_adapters/adapter_testing/find_jobs.rb b/test/active_job/queue_adapters/adapter_testing/find_jobs.rb index 4775a27d..af60d2be 100644 --- a/test/active_job/queue_adapters/adapter_testing/find_jobs.rb +++ b/test/active_job/queue_adapters/adapter_testing/find_jobs.rb @@ -9,6 +9,10 @@ module ActiveJob::QueueAdapters::AdapterTesting::FindJobs assert_job_proxy DummyJob, found_job assert_equal [ 1234 ], found_job.serialized_arguments + + found_job = ActiveJob.jobs.where(queue_name: :queue_1).find_by_id(job.job_id) + assert_job_proxy DummyJob, found_job + assert_equal [ 1234 ], found_job.serialized_arguments end test "find returns nil when not found" do diff --git a/test/active_job/queue_adapters/adapter_testing/job_batches.rb b/test/active_job/queue_adapters/adapter_testing/job_batches.rb index 6f664063..d4b42375 100644 --- a/test/active_job/queue_adapters/adapter_testing/job_batches.rb +++ b/test/active_job/queue_adapters/adapter_testing/job_batches.rb @@ -34,7 +34,7 @@ def assert_loop(jobs_count:, order:, batch_size:, expected_batches:) perform_enqueued_jobs batches = [] - ActiveJob.jobs.failed.where(job_class: "FailingJob").in_batches(of: batch_size, order: order) { |batch| batches << batch } + ActiveJob.jobs.failed.where(job_class_name: "FailingJob").in_batches(of: batch_size, order: order) { |batch| batches << batch } assert_equal expected_batches.length, batches.length batches.each { |batch| assert_instance_of ActiveJob::JobsRelation, batch } diff --git a/test/active_job/queue_adapters/adapter_testing/query_jobs.rb b/test/active_job/queue_adapters/adapter_testing/query_jobs.rb index 8a399c11..79bc75f1 100644 --- a/test/active_job/queue_adapters/adapter_testing/query_jobs.rb +++ b/test/active_job/queue_adapters/adapter_testing/query_jobs.rb @@ -85,9 +85,6 @@ module ActiveJob::QueueAdapters::AdapterTesting::QueryJobs end test "fetch failed jobs when pagination kicks in" do - WithPaginationFailingJob = Class.new(FailingJob) - WithPaginationFailingJob.default_page_size = 2 - 10.times { |index| WithPaginationFailingJob.perform_later(index) } perform_enqueued_jobs @@ -101,13 +98,10 @@ module ActiveJob::QueueAdapters::AdapterTesting::QueryJobs end test "fetch jobs when pagination kicks in with offset and limit" do - WithPaginationAndOffsetFailingJob = Class.new(FailingJob) - WithPaginationAndOffsetFailingJob.default_page_size = 2 - - 10.times { |index| WithPaginationAndOffsetFailingJob.perform_later(index) } + 10.times { |index| WithPaginationFailingJob.perform_later(index) } perform_enqueued_jobs - jobs = WithPaginationAndOffsetFailingJob.jobs.failed.offset(2).limit(3).to_a + jobs = WithPaginationFailingJob.jobs.failed.offset(2).limit(3).to_a assert_equal 3, jobs.size assert_equal [ 2 ], jobs[0].serialized_arguments @@ -115,15 +109,10 @@ module ActiveJob::QueueAdapters::AdapterTesting::QueryJobs end test "fetch pending jobs when pagination kicks in and the first pages are empty due to filtering" do - WithPaginationDummyJob1 = Class.new(DummyJob) - WithPaginationDummyJob1.default_page_size = 2 - WithPaginationDummyJob2 = Class.new(DummyJob) - WithPaginationDummyJob2.default_page_size = 2 - - 4.times { |index| WithPaginationDummyJob1.perform_later(index) } - 10.times { |index| WithPaginationDummyJob2.perform_later(index) } + 10.times { |index| WithPaginationDummyJob.perform_later(index) } + 4.times { |index| WithPaginationFailingJob.perform_later(index) } - jobs = ActiveJob::Base.jobs.where(queue: :default, job_class: WithPaginationDummyJob2).to_a + jobs = ActiveJob::Base.jobs.pending.where(queue_name: :default, job_class_name: WithPaginationDummyJob).to_a assert_equal 10, jobs.size end @@ -133,8 +122,8 @@ module ActiveJob::QueueAdapters::AdapterTesting::QueryJobs DummyJob.queue_as :queue_2 5.times { DummyJob.perform_later } - assert_equal 3, ApplicationJob.jobs.where(queue: "queue_1").to_a.length - assert_equal 5, ApplicationJob.jobs.where(queue: "queue_2").to_a.length + assert_equal 3, ApplicationJob.jobs.pending.where(queue_name: "queue_1").to_a.length + assert_equal 5, ApplicationJob.jobs.pending.where(queue_name: "queue_2").to_a.length end test "fetch job classes in the first jobs" do @@ -143,6 +132,6 @@ module ActiveJob::QueueAdapters::AdapterTesting::QueryJobs 2.times { DummyJob.perform_later } 15.times { DummyReloadedJob.perform_later } - assert_equal [ "DummyJob", "DummyReloadedJob" ], ApplicationJob.queues[:default].jobs.job_classes + assert_equal [ "DummyJob", "DummyReloadedJob" ], ApplicationJob.queues[:default].jobs.pending.job_class_names end end diff --git a/test/active_job/queue_adapters/adapter_testing/queues.rb b/test/active_job/queue_adapters/adapter_testing/queues.rb index a68f9ea0..30104d1f 100644 --- a/test/active_job/queue_adapters/adapter_testing/queues.rb +++ b/test/active_job/queue_adapters/adapter_testing/queues.rb @@ -70,13 +70,13 @@ module ActiveJob::QueueAdapters::AdapterTesting::Queues assert_not queue.empty? end - test "fetch the jobs in a queue" do + test "fetch the pending jobs in a queue" do DummyJob.queue_as :queue_1 3.times { DummyJob.perform_later } DummyJob.queue_as :queue_2 5.times { DummyJob.perform_later } - assert_equal 3, ApplicationJob.queues[:queue_1].jobs.to_a.length - assert_equal 5, ApplicationJob.queues[:queue_2].jobs.to_a.length + assert_equal 3, ApplicationJob.queues[:queue_1].jobs.pending.to_a.length + assert_equal 5, ApplicationJob.queues[:queue_2].jobs.pending.to_a.length end end diff --git a/test/active_job/queue_adapters/adapter_testing/retry_jobs.rb b/test/active_job/queue_adapters/adapter_testing/retry_jobs.rb index 5bdf7806..9bc91c3e 100644 --- a/test/active_job/queue_adapters/adapter_testing/retry_jobs.rb +++ b/test/active_job/queue_adapters/adapter_testing/retry_jobs.rb @@ -24,10 +24,7 @@ module ActiveJob::QueueAdapters::AdapterTesting::RetryJobs end test "retry all failed jobs when pagination kicks in" do - WithInternalPaginationFailingJob = Class.new(FailingJob) - WithInternalPaginationFailingJob.default_page_size = 2 - - 10.times { |index| WithInternalPaginationFailingJob.perform_later(index) } + 10.times { |index| WithPaginationFailingJob.perform_later(index) } perform_enqueued_jobs failed_jobs = ActiveJob.jobs.failed @@ -60,7 +57,29 @@ module ActiveJob::QueueAdapters::AdapterTesting::RetryJobs assert_equal 15, ActiveJob.jobs.failed.count - failed_jobs = ActiveJob.jobs.failed.where(job_class: "FailingReloadedJob") + failed_jobs = ActiveJob.jobs.failed.where(job_class_name: "FailingReloadedJob") + failed_jobs.retry_all + + assert_equal 10, ActiveJob.jobs.failed.count + + assert_not ActiveJob.jobs.failed.any? { |job| job.is_a?(FailingReloadedJob) } + + perform_enqueued_jobs + assert_equal 1 * 10, FailingJob.invocations.count + assert_equal 2 * 5, FailingReloadedJob.invocations.count + end + + test "retry all failed of a given queue" do + FailingJob.queue_as :queue_1 + FailingReloadedJob.queue_as :queue_2 + + 10.times { |index| FailingJob.perform_later(index) } + 5.times { |index| FailingReloadedJob.perform_later(index) } + perform_enqueued_jobs + + assert_equal 15, ActiveJob.jobs.failed.count + + failed_jobs = ActiveJob.jobs.failed.where(queue_name: :queue_2) failed_jobs.retry_all assert_equal 10, ActiveJob.jobs.failed.count diff --git a/test/active_job/queue_adapters/solid_queue_test.rb b/test/active_job/queue_adapters/solid_queue_test.rb index 0d2d1fa9..c083edf8 100644 --- a/test/active_job/queue_adapters/solid_queue_test.rb +++ b/test/active_job/queue_adapters/solid_queue_test.rb @@ -13,7 +13,8 @@ def queue_adapter end def perform_enqueued_jobs - worker = SolidQueue::Worker.new(queues: "*", pool_size: 3, polling_interval: 0) - worker.start(mode: :inline) + worker = SolidQueue::Worker.new(queues: "*", threads: 1, polling_interval: 0) + worker.mode = :inline + worker.start end end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 44a0044d..d103f6de 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,7 +1,7 @@ require_relative "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 900 ] + driven_by :selenium_chrome_headless, screen_size: [ 1200, 1000 ] include UIHelper diff --git a/test/controllers/.keep b/test/controllers/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/controllers/jobs_controller_test.rb b/test/controllers/jobs_controller_test.rb new file mode 100644 index 00000000..6d3a45d8 --- /dev/null +++ b/test/controllers/jobs_controller_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class MissionControl::Jobs::JobsControllerTest < ActionDispatch::IntegrationTest + setup do + DummyJob.queue_as :queue_1 + @job = DummyJob.perform_later(42) + end + + test "get job details" do + get mission_control_jobs.application_job_url(@application, @job.job_id) + assert_response :ok + + assert_includes response.body, @job.job_id + assert_includes response.body, "queue_1" + + get mission_control_jobs.application_job_url(@application, @job.job_id, filter: { queue_name: "queue_1" }) + assert_response :ok + + assert_includes response.body, @job.job_id + assert_includes response.body, "queue_1" + end + + test "redirect to queue when job doesn't exist" do + get mission_control_jobs.application_job_url(@application, @job.job_id + "0", filter: { queue_name: "queue_1" }) + assert_redirected_to mission_control_jobs.application_queue_path(@application, :queue_1) + end +end diff --git a/test/controllers/workers_controller_test.rb b/test/controllers/workers_controller_test.rb new file mode 100644 index 00000000..ffddded2 --- /dev/null +++ b/test/controllers/workers_controller_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class MissionControl::Jobs::WorkersControllerTest < ActionDispatch::IntegrationTest + setup do + 2.times { PauseJob.perform_later } + Socket.stubs(:gethostname).returns("my-hostname-123") + end + + test "get workers" do + perform_enqueued_jobs_async + worker = @server.workers.first + + get mission_control_jobs.application_workers_url(@application) + + assert_includes response.body, "worker #{worker.id}" + assert_includes response.body, "PauseJob" + assert_includes response.body, "my-hostname-123" + end + + test "get worker details" do + perform_enqueued_jobs_async + worker = @server.workers.first + + get mission_control_jobs.application_worker_url(@application, worker.id) + assert_response :ok + + assert_includes response.body, "Worker #{worker.id}" + assert_includes response.body, "Running 2 jobs" + end +end diff --git a/test/dummy/app/jobs/pause_job.rb b/test/dummy/app/jobs/pause_job.rb new file mode 100644 index 00000000..0f58711d --- /dev/null +++ b/test/dummy/app/jobs/pause_job.rb @@ -0,0 +1,5 @@ +class PauseJob < ApplicationJob + def perform(time = 1) + sleep(time) + end +end diff --git a/test/dummy/app/jobs/with_pagination_dummy_job.rb b/test/dummy/app/jobs/with_pagination_dummy_job.rb new file mode 100644 index 00000000..96be0363 --- /dev/null +++ b/test/dummy/app/jobs/with_pagination_dummy_job.rb @@ -0,0 +1,3 @@ +class WithPaginationDummyJob < DummyJob + self.default_page_size = 2 +end diff --git a/test/dummy/app/jobs/with_pagination_failing_job.rb b/test/dummy/app/jobs/with_pagination_failing_job.rb new file mode 100644 index 00000000..55cd285e --- /dev/null +++ b/test/dummy/app/jobs/with_pagination_failing_job.rb @@ -0,0 +1,3 @@ +class WithPaginationFailingJob < FailingJob + self.default_page_size = 2 +end diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb index d98c6055..88c7bfc5 100644 --- a/test/dummy/config/environments/development.rb +++ b/test/dummy/config/environments/development.rb @@ -69,4 +69,7 @@ # config.action_cable.disable_request_forgery_protection = true config.active_job.queue_adapter = :resque + + # Silence Solid Queue logging + config.solid_queue.logger = ActiveSupport::Logger.new(nil) end diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb index da9b37db..21c739a8 100644 --- a/test/dummy/config/environments/test.rb +++ b/test/dummy/config/environments/test.rb @@ -28,7 +28,7 @@ config.cache_store = :null_store # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = false + config.action_dispatch.show_exceptions = :none # Disable request forgery protection in test environment. config.action_controller.allow_forgery_protection = false @@ -58,4 +58,7 @@ # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true config.active_job.queue_adapter = :resque + + # Silence Solid Queue logging + config.solid_queue.logger = ActiveSupport::Logger.new(nil) end diff --git a/test/dummy/config/initializers/mission_control_jobs.rb b/test/dummy/config/initializers/mission_control_jobs.rb index 24ccc49d..c074dc77 100644 --- a/test/dummy/config/initializers/mission_control_jobs.rb +++ b/test/dummy/config/initializers/mission_control_jobs.rb @@ -6,8 +6,8 @@ Resque.redis = Redis::Namespace.new "#{Rails.env}", redis: Redis.new(host: "localhost", port: 6379, thread_safe: true) SERVERS_BY_APP = { - BC3: %w[ ashburn chicago ], - HEY: %w[ us-east-1 ] + BC4: %w[ resque_ashburn resque_chicago ], + HEY: %w[ resque solid_queue ] } def redis_connection_for(app, server) @@ -17,7 +17,12 @@ def redis_connection_for(app, server) SERVERS_BY_APP.each do |app, servers| queue_adapters_by_name = servers.collect do |server| - queue_adapter = ActiveJob::QueueAdapters::ResqueAdapter.new(redis_connection_for(app, server)) + queue_adapter = if server.start_with?("resque") + ActiveJob::QueueAdapters::ResqueAdapter.new(redis_connection_for(app, server)) + else + ActiveJob::QueueAdapters::SolidQueueAdapter.new + end + [ server, queue_adapter ] end.to_h diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 0ea6bae9..5b785b70 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -1,3 +1,5 @@ Rails.application.routes.draw do + root to: redirect("/jobs") + mount MissionControl::Jobs::Engine => "/jobs" end diff --git a/test/dummy/db/migrate/20221018092331_create_posts.rb b/test/dummy/db/migrate/20221018092331_create_posts.rb index 7f64518d..a40a4fe9 100644 --- a/test/dummy/db/migrate/20221018092331_create_posts.rb +++ b/test/dummy/db/migrate/20221018092331_create_posts.rb @@ -1,4 +1,4 @@ -class CreatePosts < ActiveRecord::Migration[7.0] +class CreatePosts < ActiveRecord::Migration[7.1] def change create_table :posts do |t| t.string :title diff --git a/test/dummy/db/migrate/20230914113326_create_solid_queue_tables.solid_queue.rb b/test/dummy/db/migrate/20230914113326_create_solid_queue_tables.solid_queue.rb index f7439480..eace492c 100644 --- a/test/dummy/db/migrate/20230914113326_create_solid_queue_tables.solid_queue.rb +++ b/test/dummy/db/migrate/20230914113326_create_solid_queue_tables.solid_queue.rb @@ -1,57 +1,67 @@ -# This migration comes from solid_queue (originally 20230207182223) -class CreateSolidQueueTables < ActiveRecord::Migration[7.0] +class CreateSolidQueueTables < ActiveRecord::Migration[7.1] def change create_table :solid_queue_jobs do |t| t.string :queue_name, null: false t.string :class_name, null: false, index: true - t.text :arguments - - t.string :active_job_id - t.integer :priority, default: 0, null: false - + t.string :active_job_id, index: true t.datetime :scheduled_at - t.datetime :finished_at + t.datetime :finished_at, index: true + t.string :concurrency_key t.timestamps - t.index :active_job_id, name: "index_solid_queue_jobs_on_job_id" - t.index [ :queue_name, :scheduled_at, :finished_at ], name: "index_solid_queue_jobs_for_alerting" + t.index [ :queue_name, :finished_at ], name: "index_solid_queue_jobs_for_filtering" + t.index [ :scheduled_at, :finished_at ], name: "index_solid_queue_jobs_for_alerting" end create_table :solid_queue_scheduled_executions do |t| - t.references :job, index: { unique: true } + t.references :job, index: { unique: true }, null: false t.string :queue_name, null: false t.integer :priority, default: 0, null: false t.datetime :scheduled_at, null: false t.datetime :created_at, null: false - t.index [ :scheduled_at, :priority ], name: "index_solid_queue_scheduled_executions" + t.index [ :scheduled_at, :priority, :job_id ], name: "index_solid_queue_dispatch_all" end create_table :solid_queue_ready_executions do |t| - t.references :job, index: { unique: true } + t.references :job, index: { unique: true }, null: false t.string :queue_name, null: false - t.integer :priority, default: 0, null: false, index: true + t.integer :priority, default: 0, null: false t.datetime :created_at, null: false - t.index [ :queue_name, :priority ], name: "index_solid_queue_ready_executions" + t.index [ :priority, :job_id ], name: "index_solid_queue_poll_all" + t.index [ :queue_name, :priority, :job_id ], name: "index_solid_queue_poll_by_queue" end create_table :solid_queue_claimed_executions do |t| - t.references :job, index: { unique: true } - t.references :process, index: true + t.references :job, index: { unique: true }, null: false + t.bigint :process_id + t.datetime :created_at, null: false + + t.index [ :process_id, :job_id ] + end + + create_table :solid_queue_blocked_executions do |t| + t.references :job, index: { unique: true }, null: false + t.string :queue_name, null: false + t.integer :priority, default: 0, null: false + t.string :concurrency_key, null: false + t.datetime :expires_at, null: false t.datetime :created_at, null: false + + t.index [ :expires_at, :concurrency_key ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ :concurrency_key, :priority, :job_id ], name: "index_solid_queue_blocked_executions_for_release" end create_table :solid_queue_failed_executions do |t| - t.references :job, index: { unique: true } + t.references :job, index: { unique: true }, null: false t.text :error - t.datetime :created_at, null: false end @@ -61,9 +71,31 @@ def change end create_table :solid_queue_processes do |t| + t.string :kind, null: false + t.datetime :last_heartbeat_at, null: false, index: true + t.bigint :supervisor_id, index: true + + t.integer :pid, null: false + t.string :hostname t.text :metadata + t.datetime :created_at, null: false - t.datetime :last_heartbeat_at, null: false, index: true end + + create_table :solid_queue_semaphores do |t| + t.string :key, null: false, index: { unique: true } + t.integer :value, default: 1, null: false + t.datetime :expires_at, null: false, index: true + + t.timestamps + + t.index [ :key, :value ], name: "index_solid_queue_semaphores_on_key_and_value" + end + + add_foreign_key :solid_queue_blocked_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_claimed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_failed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_ready_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade + add_foreign_key :solid_queue_scheduled_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade end end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index d266e382..fe0fc9d7 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_09_14_113326) do +ActiveRecord::Schema[7.1].define(version: 2023_09_14_113326) do create_table "posts", force: :cascade do |t| t.string "title" t.text "body" @@ -18,16 +18,28 @@ t.datetime "updated_at", null: false end + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.integer "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + create_table "solid_queue_claimed_executions", force: :cascade do |t| - t.integer "job_id" - t.integer "process_id" + t.integer "job_id", null: false + t.bigint "process_id" t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true - t.index ["process_id"], name: "index_solid_queue_claimed_executions_on_process_id" + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.integer "job_id" + t.integer "job_id", null: false t.text "error" t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true @@ -37,15 +49,18 @@ t.string "queue_name", null: false t.string "class_name", null: false t.text "arguments" - t.string "active_job_id" t.integer "priority", default: 0, null: false + t.string "active_job_id" t.datetime "scheduled_at" t.datetime "finished_at" + t.string "concurrency_key" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["active_job_id"], name: "index_solid_queue_jobs_on_job_id" + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" - t.index ["queue_name", "scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end create_table "solid_queue_pauses", force: :cascade do |t| @@ -55,30 +70,51 @@ end create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" t.text "metadata" t.datetime "created_at", null: false - t.datetime "last_heartbeat_at", null: false t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| - t.integer "job_id" + t.integer "job_id", null: false t.string "queue_name", null: false t.integer "priority", default: 0, null: false t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true - t.index ["priority"], name: "index_solid_queue_ready_executions_on_priority" - t.index ["queue_name", "priority"], name: "index_solid_queue_ready_executions" + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| - t.integer "job_id" + t.integer "job_id", null: false t.string "queue_name", null: false t.integer "priority", default: 0, null: false t.datetime "scheduled_at", null: false t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true - t.index ["scheduled_at", "priority"], name: "index_solid_queue_scheduled_executions" + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade end diff --git a/test/dummy/db/seeds.rb b/test/dummy/db/seeds.rb index 62125400..13d0337b 100644 --- a/test/dummy/db/seeds.rb +++ b/test/dummy/db/seeds.rb @@ -3,18 +3,25 @@ def clean_redis Resque.redis.del all_keys if all_keys.any? end +def clean_database + SolidQueue::Job.all.each(&:destroy) + SolidQueue::Process.all.each(&:destroy) +end + class JobsLoader - attr_reader :application, :server, :failed_jobs_count, :regular_jobs_count + attr_reader :application, :server, :failed_jobs_count, :regular_jobs_count, :finished_jobs_count def initialize(application, server, failed_jobs_count: 100, regular_jobs_count: 50) @application = application @server = server @failed_jobs_count = randomize(failed_jobs_count) @regular_jobs_count = randomize(regular_jobs_count) + @finished_jobs_count = randomize(regular_jobs_count) end def load server.activating do + load_finished_jobs load_failed_jobs load_regular_jobs end @@ -22,22 +29,35 @@ def load private def load_failed_jobs - puts "Generating #{failed_jobs_count} failed jobs for #{application} - #{server} at #{current_redis.inspect}..." + puts "Generating #{failed_jobs_count} failed jobs for #{application} - #{server}..." failed_jobs_count.times { |index| enqueue_one_of FailingJob => index, FailingReloadedJob => index, FailingPostJob => [ Post.last, 1.year.ago ] } dispatch_jobs end - def current_redis - Resque.redis.instance_variable_get("@redis") + def dispatch_jobs + case server.queue_adapter_name + when :resque + worker = Resque::Worker.new("*") + worker.work(0.0) + when :solid_queue + worker = SolidQueue::Worker.new(queues: "*", threads: 1, polling_interval: 0) + worker.mode = :inline + worker.start + else + raise "Don't know how to dispatch jobs for #{server.queue_adapter_name} adapter" + end end - def dispatch_jobs - worker = Resque::Worker.new("*") - worker.work(0.0) + def load_finished_jobs + puts "Generating #{finished_jobs_count} finished jobs for #{application} - #{server}..." + regular_jobs_count.times do |index| + enqueue_one_of DummyJob => index, DummyReloadedJob => index + end + dispatch_jobs end def load_regular_jobs - puts "Generating #{regular_jobs_count} regular jobs..." + puts "Generating #{regular_jobs_count} regular jobs for #{application} - #{server}..." regular_jobs_count.times do |index| enqueue_one_of DummyJob => index, DummyReloadedJob => index end @@ -63,6 +83,7 @@ def randomize(value) puts "Deleting existing jobs..." clean_redis +clean_database BASE_COUNT = (ENV["COUNT"].presence || 100).to_i diff --git a/test/integration/navigation_test.rb b/test/integration/navigation_test.rb deleted file mode 100644 index ebbc098a..00000000 --- a/test/integration/navigation_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class NavigationTest < ActionDispatch::IntegrationTest - # test "the truth" do - # assert true - # end -end diff --git a/test/mission_control/jobs/server/serializable_test.rb b/test/mission_control/jobs/server/serializable_test.rb index 5c72cdda..52fea7b8 100644 --- a/test/mission_control/jobs/server/serializable_test.rb +++ b/test/mission_control/jobs/server/serializable_test.rb @@ -2,27 +2,27 @@ class MissionControl::Jobs::Server::SerializableTest < ActiveSupport::TestCase setup do - @bc3_chicago = MissionControl::Jobs.applications[:bc3].servers[:chicago] - @hey = MissionControl::Jobs.applications[:hey].servers.first + @bc4_chicago = MissionControl::Jobs.applications[:bc4].servers[:resque_chicago] + @hey = MissionControl::Jobs.applications[:hey].servers[:solid_queue] end test "generate a global id for a server" do - assert_equal "bc3:chicago", @bc3_chicago.to_global_id - assert_equal "hey", @hey.to_global_id + assert_equal "bc4:resque_chicago", @bc4_chicago.to_global_id + assert_equal "hey:solid_queue", @hey.to_global_id end test "locate a server for a global id" do - assert_equal @bc3_chicago, MissionControl::Jobs::Server.from_global_id("bc3:chicago") - assert_equal @hey, MissionControl::Jobs::Server.from_global_id("hey") + assert_equal @bc4_chicago, MissionControl::Jobs::Server.from_global_id("bc4:resque_chicago") + assert_equal @hey, MissionControl::Jobs::Server.from_global_id("hey:solid_queue") end test "raise an error when trying to locate a missing server" do assert_raises MissionControl::Jobs::Errors::ResourceNotFound do - MissionControl::Jobs::Server.from_global_id("bc3:paris") + MissionControl::Jobs::Server.from_global_id("bc4:resque_paris") end assert_raises MissionControl::Jobs::Errors::ResourceNotFound do - MissionControl::Jobs::Server.from_global_id("backpack:chicago") + MissionControl::Jobs::Server.from_global_id("backpack:resque_chicago") end assert_raises MissionControl::Jobs::Errors::ResourceNotFound do diff --git a/test/mission_control/jobs/server_test.rb b/test/mission_control/jobs/server_test.rb index b4dc38b2..6a97c816 100644 --- a/test/mission_control/jobs/server_test.rb +++ b/test/mission_control/jobs/server_test.rb @@ -2,13 +2,13 @@ class MissionControl::Jobs::ServerTest < ActiveSupport::TestCase setup do - @application = MissionControl::Jobs.applications[:bc3] + @application = MissionControl::Jobs.applications[:bc4] end test "activating a queue adapter" do current_adapter = ActiveJob::Base.queue_adapter new_adapter = ActiveJob::QueueAdapters::ResqueAdapter.new - server = MissionControl::Jobs::Server.new(name: "chicago", queue_adapter: new_adapter, application: @bc3) + server = MissionControl::Jobs::Server.new(name: "resque_chicago", queue_adapter: new_adapter, application: @application) assert_equal current_adapter, ActiveJob::Base.queue_adapter diff --git a/test/models/.keep b/test/models/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/support/jobs_helper.rb b/test/support/jobs_helper.rb index 02d53089..ddb5d135 100644 --- a/test/support/jobs_helper.rb +++ b/test/support/jobs_helper.rb @@ -3,7 +3,7 @@ module JobsHelper def assert_job_proxy(expected_class, job) assert_instance_of ActiveJob::JobProxy, job - assert_equal expected_class.to_s, job.class_name + assert_equal expected_class.to_s, job.job_class_name end def within_job_server(app_id, server: nil, &block) diff --git a/test/system/change_apps_and_servers_test.rb b/test/system/change_apps_and_servers_test.rb index 840dbf97..b7ffb027 100644 --- a/test/system/change_apps_and_servers_test.rb +++ b/test/system/change_apps_and_servers_test.rb @@ -20,29 +20,29 @@ class ChangeAppsAndServersTest < ApplicationSystemTestCase end test "switch job servers" do - DummyJob.queue_as :bc3_queue + DummyJob.queue_as :bc4_queue - within_job_server "bc3", server: "ashburn" do + within_job_server "bc4", server: "resque_ashburn" do 5.times { |index| DummyJob.perform_later(index) } end - within_job_server "bc3", server: "chicago" do - DummyJob.queue_as :bc3_queue_chicago + within_job_server "bc4", server: "resque_chicago" do + DummyJob.queue_as :bc4_queue_chicago 10.times { |index| DummyJob.perform_later(index) } end visit queues_path - click_on "bc3_queue" + click_on "bc4_queue" assert_equal 5, job_row_elements.length - click_on_server_selector "chicago" + click_on_server_selector "resque_chicago" assert_text 10 - click_on "bc3_queue" + click_on "bc4_queue" assert_equal 10, job_row_elements.length - click_on_server_selector "ashburn" + click_on_server_selector "resque_ashburn" assert_text 5 - click_on "bc3_queue" + click_on "bc4_queue" assert_equal 5, job_row_elements.length end end diff --git a/test/system/discard_jobs_test.rb b/test/system/discard_jobs_test.rb index 497cf4e5..78286b25 100644 --- a/test/system/discard_jobs_test.rb +++ b/test/system/discard_jobs_test.rb @@ -2,26 +2,27 @@ class DiscardJobsTest < ApplicationSystemTestCase setup do - 5.times { |index| FailingJob.perform_later(index) } - 5.times { |index| FailingReloadedJob.perform_later(5 + index) } + 4.times { |index| FailingJob.set(queue: "queue_1").perform_later(index) } + 3.times { |index| FailingReloadedJob.set(queue: "queue_2").perform_later(4 + index) } + 2.times { |index| FailingJob.set(queue: "queue_2").perform_later(7 + index) } perform_enqueued_jobs - visit failed_jobs_path + visit jobs_path(:failed) end test "discard all failed jobs" do - assert_equal 10, job_row_elements.length + assert_equal 9, job_row_elements.length accept_confirm do click_on "Discard all" end - assert_text "Discarded 10 jobs" + assert_text "Discarded 9 jobs" assert_empty job_row_elements end test "discard a single job" do - assert_equal 10, job_row_elements.length + assert_equal 9, job_row_elements.length expected_job_id = ApplicationJob.jobs.failed[2].job_id within_job_row "2" do @@ -32,33 +33,47 @@ class DiscardJobsTest < ApplicationSystemTestCase assert_text "Discarded job with id #{expected_job_id}" + assert_equal 8, job_row_elements.length + end + + test "discard a selection of filtered jobs by class name" do assert_equal 9, job_row_elements.length + + fill_in "filter[job_class_name]", with: "FailingReloadedJob" + assert_text /3 jobs found/i + + accept_confirm do + click_on "Discard selection" + end + + assert_text /discarded 3 jobs/i + assert_equal 6, job_row_elements.length end - test "retry a selection of filtered jobs" do - assert_equal 10, job_row_elements.length + test "discard a selection of filtered jobs by queue name" do + assert_equal 9, job_row_elements.length - fill_in "filter[job_class]", with: "FailingJob" - assert_text /5 jobs selected/i + fill_in "filter[queue_name]", with: "queue_2" + assert_text /5 jobs found/i accept_confirm do click_on "Discard selection" end assert_text /discarded 5 jobs/i - assert_equal 5, job_row_elements.length + assert_equal 4, job_row_elements.length end test "discard a job from its details screen" do - assert_equal 10, job_row_elements.length + assert_equal 9, job_row_elements.length failed_job = ApplicationJob.jobs.failed[2] - visit failed_job_path(failed_job.job_id) + visit job_path(failed_job.job_id) accept_confirm do click_on "Discard" end assert_text "Discarded job with id #{failed_job.job_id}" - assert_equal 9, job_row_elements.length + assert_equal 8, job_row_elements.length end end diff --git a/test/system/list_failed_jobs_test.rb b/test/system/list_failed_jobs_test.rb index 8c283dc0..cf00c98e 100644 --- a/test/system/list_failed_jobs_test.rb +++ b/test/system/list_failed_jobs_test.rb @@ -5,7 +5,7 @@ class ListFailedJobsTest < ApplicationSystemTestCase 10.times { |index| FailingJob.perform_later(index) } perform_enqueued_jobs - visit failed_jobs_path + visit jobs_path(:failed) end test "view the failed jobs" do diff --git a/test/system/paginate_jobs_test.rb b/test/system/paginate_jobs_test.rb index 452e8c3c..48156034 100644 --- a/test/system/paginate_jobs_test.rb +++ b/test/system/paginate_jobs_test.rb @@ -5,7 +5,7 @@ class PaginateJobsTest < ApplicationSystemTestCase 20.times { |index| FailingJob.perform_later(index) } perform_enqueued_jobs - visit failed_jobs_path + visit jobs_path(:failed) end test "paginate failed jobs" do diff --git a/test/system/retry_jobs_test.rb b/test/system/retry_jobs_test.rb index 1f254ae6..3931f893 100644 --- a/test/system/retry_jobs_test.rb +++ b/test/system/retry_jobs_test.rb @@ -2,24 +2,25 @@ class RetryJobsTest < ApplicationSystemTestCase setup do - 5.times { |index| FailingJob.perform_later(index) } - 5.times { |index| FailingReloadedJob.perform_later(5 + index) } + 4.times { |index| FailingJob.set(queue: "queue_1").perform_later(index) } + 3.times { |index| FailingReloadedJob.set(queue: "queue_2").perform_later(4 + index) } + 2.times { |index| FailingJob.set(queue: "queue_2").perform_later(7 + index) } perform_enqueued_jobs - visit failed_jobs_path + visit jobs_path(:failed) end test "retry all failed jobs" do - assert_equal 10, job_row_elements.length + assert_equal 9, job_row_elements.length click_on "Retry all" - assert_text "Retried 10 jobs" + assert_text "Retried 9 jobs" assert_empty job_row_elements end test "retry a single job" do - assert_equal 10, job_row_elements.length + assert_equal 9, job_row_elements.length expected_job_id = ApplicationJob.jobs.failed[2].job_id within_job_row "2" do @@ -28,28 +29,39 @@ class RetryJobsTest < ApplicationSystemTestCase assert_text "Retried job with id #{expected_job_id}" + assert_equal 8, job_row_elements.length + end + + test "retry a selection of filtered jobs by class name" do assert_equal 9, job_row_elements.length + + fill_in "filter[job_class_name]", with: "FailingJob" + assert_text /6 jobs found/i + + click_on "Retry selection" + assert_text /retried 6 jobs/i + assert_equal 3, job_row_elements.length end - test "retry a selection of filtered jobs" do - assert_equal 10, job_row_elements.length + test "retry a selection of filtered jobs by queue name" do + assert_equal 9, job_row_elements.length - fill_in "filter[job_class]", with: "FailingJob" - assert_text /5 jobs selected/i + fill_in "filter[queue_name]", with: "queue_1" + assert_text /4 jobs found/i click_on "Retry selection" - assert_text /retried 5 jobs/i + assert_text /retried 4 jobs/i assert_equal 5, job_row_elements.length end test "retry a job from its details screen" do - assert_equal 10, job_row_elements.length + assert_equal 9, job_row_elements.length failed_job = ApplicationJob.jobs.failed[2] - visit failed_job_path(failed_job.job_id) + visit job_path(failed_job.job_id) click_on "Retry" assert_text "Retried job with id #{failed_job.job_id}" - assert_equal 9, job_row_elements.length + assert_equal 8, job_row_elements.length end end diff --git a/test/system/show_failed_job_test.rb b/test/system/show_failed_job_test.rb index 3d4b2d6e..df12e81d 100644 --- a/test/system/show_failed_job_test.rb +++ b/test/system/show_failed_job_test.rb @@ -4,7 +4,7 @@ class ShowFailedJobsTest < ApplicationSystemTestCase setup do 10.times { |index| FailingJob.perform_later(index) } perform_enqueued_jobs - visit failed_jobs_path + visit jobs_path(:failed) end test "click on a failed job to see its details" do @@ -26,7 +26,7 @@ class ShowFailedJobsTest < ApplicationSystemTestCase test "show empty notice when no jobs" do ActiveJob.jobs.failed.discard_all - visit failed_jobs_path + visit jobs_path(:failed) assert_text /there are no failed jobs/i end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 2a62d5c2..ac822bb0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,11 +8,13 @@ require "rails/test_help" require "mocha/minitest" +require "debug" + # Load fixtures from the engine if ActiveSupport::TestCase.respond_to?(:fixture_path=) - ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) - ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path - ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" + ActiveSupport::TestCase.fixture_paths = [ File.expand_path("fixtures", __dir__) ] + ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths + ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_paths.first + "/files" ActiveSupport::TestCase.fixtures :all end @@ -68,3 +70,27 @@ def reset_configured_queues_for_job_classes ApplicationJob.descendants.including(ApplicationJob).each { |klass| klass.queue_as :default } end end + +class ActionDispatch::IntegrationTest + # Integration tests just use Solid Queue for now + setup do + MissionControl::Jobs.applications.add("integration-tests", { solid_queue: queue_adapter_for_test}) + + @application = MissionControl::Jobs.applications["integration-tests"] + @server = @application.servers[:solid_queue] + @worker = SolidQueue::Worker.new(queues: "*", threads: 2, polling_interval: 0) + end + + teardown do + @worker.stop + end + + private + def queue_adapter_for_test + ActiveJob::QueueAdapters::SolidQueueAdapter.new + end + + def perform_enqueued_jobs_async + @worker.start + end +end