├── test ├── helpers │ └── .keep ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── post.rb │ │ │ └── application_record.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── jobs │ │ │ ├── dummy_job.rb │ │ │ ├── with_pagination_dummy_job.rb │ │ │ ├── dummy_reloaded_job.rb │ │ │ ├── pause_job.rb │ │ │ ├── with_pagination_failing_job.rb │ │ │ ├── failing_job.rb │ │ │ ├── failing_reloaded_job.rb │ │ │ ├── blocking_job.rb │ │ │ ├── failing_post_job.rb │ │ │ ├── auto_retrying_job.rb │ │ │ └── application_job.rb │ │ └── channels │ │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── test │ │ └── fixtures │ │ │ └── posts.yml │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── routes.rb │ │ ├── environment.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── initializers │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── assets.rb │ │ │ ├── inflections.rb │ │ │ ├── mission_control_jobs.rb │ │ │ └── content_security_policy.rb │ │ ├── solid_queue.yml │ │ ├── database.yml │ │ ├── locales │ │ │ └── en.yml │ │ ├── application.rb │ │ ├── storage.yml │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── config.ru │ ├── db │ │ ├── migrate │ │ │ ├── 20221018092331_create_posts.rb │ │ │ ├── 20240909204134_add_name_to_processes.solid_queue.rb │ │ │ ├── 20240909204136_change_solid_queue_recurring_tasks_static_to_not_null.solid_queue.rb │ │ │ ├── 20240806160416_create_recurring_tasks.solid_queue.rb │ │ │ ├── 20240909204135_make_name_not_null.solid_queue.rb │ │ │ └── 20230914113326_create_solid_queue_tables.solid_queue.rb │ │ └── seeds.rb │ └── Rakefile ├── fixtures │ └── files │ │ └── .keep ├── support │ ├── thread_helper.rb │ ├── job_queues_helper.rb │ ├── jobs_helper.rb │ ├── resque_helper.rb │ └── ui_helper.rb ├── mission_control │ ├── jobs_test.rb │ ├── jobs │ │ ├── base_application_controller_test.rb │ │ ├── workers_relation_test.rb │ │ ├── application_test.rb │ │ ├── server_test.rb │ │ ├── applications_test.rb │ │ └── server │ │ │ └── serializable_test.rb │ └── erb_inline_styles_test.rb ├── active_job │ ├── job_proxy_test.rb │ ├── queue_adapters │ │ ├── solid_queue_test.rb │ │ ├── queue_adapter_test.rb │ │ ├── adapter_testing │ │ │ ├── dispatch_jobs.rb │ │ │ ├── find_jobs.rb │ │ │ ├── job_batches.rb │ │ │ ├── queues.rb │ │ │ ├── discard_jobs.rb │ │ │ └── retry_jobs.rb │ │ ├── adapter_testing.rb │ │ ├── resque_test.rb │ │ └── resque_adapter_test.rb │ ├── queues_test.rb │ └── jobs_relation_test.rb ├── system │ ├── list_queues_test.rb │ ├── show_queue_job_test.rb │ ├── list_failed_jobs_test.rb │ ├── list_queue_jobs_test.rb │ ├── pause_queues_test.rb │ ├── paginate_jobs_test.rb │ ├── change_apps_and_servers_test.rb │ ├── retry_jobs_test.rb │ ├── discard_jobs_test.rb │ └── show_failed_job_test.rb ├── application_system_test_case.rb ├── controllers │ ├── retries_controller_test.rb │ ├── workers_controller_test.rb │ ├── recurring_tasks_controller_test.rb │ └── jobs_controller_test.rb └── test_helper.rb ├── .ruby-version ├── app ├── assets │ ├── images │ │ └── mission_control │ │ │ └── jobs │ │ │ └── .keep │ ├── stylesheets │ │ └── mission_control │ │ │ └── jobs │ │ │ ├── forms.css │ │ │ ├── jobs.css │ │ │ └── application.css │ └── config │ │ └── mission_control_jobs_manifest.js ├── javascript │ └── mission_control │ │ └── jobs │ │ ├── helpers │ │ ├── index.js │ │ └── debounce_helpers.js │ │ ├── application.js │ │ └── controllers │ │ ├── application.js │ │ ├── form_controller.js │ │ └── index.js ├── models │ └── mission_control │ │ └── jobs │ │ ├── current.rb │ │ ├── application_record.rb │ │ ├── worker.rb │ │ ├── recurring_task.rb │ │ └── page.rb ├── views │ ├── mission_control │ │ └── jobs │ │ │ ├── jobs │ │ │ ├── _raw_data.html.erb │ │ │ ├── finished │ │ │ │ └── _job.html.erb │ │ │ ├── blocked │ │ │ │ ├── _actions.html.erb │ │ │ │ └── _job.html.erb │ │ │ ├── scheduled │ │ │ │ ├── _actions.html.erb │ │ │ │ └── _job.html.erb │ │ │ ├── failed │ │ │ │ ├── _job.html.erb │ │ │ │ ├── _actions.html.erb │ │ │ │ └── _backtrace_toggle.html.erb │ │ │ ├── show.html.erb │ │ │ ├── in_progress │ │ │ │ └── _job.html.erb │ │ │ ├── _jobs_page.html.erb │ │ │ ├── _job.html.erb │ │ │ ├── _title.html.erb │ │ │ ├── _error_information.html.erb │ │ │ ├── _toolbar.html.erb │ │ │ ├── index.html.erb │ │ │ ├── _general_information.html.erb │ │ │ └── _filters.html.erb │ │ │ ├── workers │ │ │ ├── _raw_data.html.erb │ │ │ ├── _configuration.html.erb │ │ │ ├── index.html.erb │ │ │ ├── _title.html.erb │ │ │ ├── _workers_page.html.erb │ │ │ ├── show.html.erb │ │ │ └── _worker.html.erb │ │ │ ├── recurring_tasks │ │ │ ├── _title.html.erb │ │ │ ├── index.html.erb │ │ │ ├── show.html.erb │ │ │ ├── _recurring_task.html.erb │ │ │ └── _general_information.html.erb │ │ │ ├── shared │ │ │ ├── _jobs.html.erb │ │ │ ├── _pagination_toolbar.html.erb │ │ │ └── _job.html.erb │ │ │ └── queues │ │ │ ├── index.html.erb │ │ │ ├── _actions.html.erb │ │ │ ├── _queue.html.erb │ │ │ ├── _job.html.erb │ │ │ ├── _queue_title.html.erb │ │ │ └── show.html.erb │ └── layouts │ │ └── mission_control │ │ └── jobs │ │ ├── _navigation.html.erb │ │ ├── _flash.html.erb │ │ ├── _application_selection.html.erb │ │ ├── application_selection │ │ ├── _applications.html.erb │ │ └── _servers.html.erb │ │ └── application.html.erb ├── helpers │ └── mission_control │ │ └── jobs │ │ ├── dates_helper.rb │ │ ├── application_helper.rb │ │ ├── interface_helper.rb │ │ ├── navigation_helper.rb │ │ └── jobs_helper.rb └── controllers │ ├── concerns │ └── mission_control │ │ └── jobs │ │ ├── job_scoped.rb │ │ ├── queue_scoped.rb │ │ ├── job_filters.rb │ │ ├── failed_jobs_bulk_operations.rb │ │ ├── adapter_features.rb │ │ ├── not_found_redirections.rb │ │ └── application_scoped.rb │ └── mission_control │ └── jobs │ ├── retries_controller.rb │ ├── dispatches_controller.rb │ ├── bulk_retries_controller.rb │ ├── queues │ └── pauses_controller.rb │ ├── application_controller.rb │ ├── queues_controller.rb │ ├── discards_controller.rb │ ├── workers_controller.rb │ ├── bulk_discards_controller.rb │ ├── recurring_tasks_controller.rb │ └── jobs_controller.rb ├── docs └── images │ ├── workers.png │ ├── single-job.png │ ├── default-queue.png │ ├── queues-simple.png │ ├── single-worker.png │ ├── in-progress-jobs.png │ ├── queues-multiple.png │ └── failed-jobs-simple.png ├── lib ├── mission_control │ ├── jobs │ │ ├── version.rb │ │ ├── errors │ │ │ ├── resource_not_found.rb │ │ │ └── incompatible_adapter.rb │ │ ├── console │ │ │ ├── context.rb │ │ │ ├── connect_to.rb │ │ │ └── jobs_help.rb │ │ ├── applications.rb │ │ ├── identified_by_name.rb │ │ ├── server │ │ │ ├── workers.rb │ │ │ ├── recurring_tasks.rb │ │ │ └── serializable.rb │ │ ├── identified_elements.rb │ │ ├── application.rb │ │ ├── server.rb │ │ ├── workers_relation.rb │ │ ├── engine.rb │ │ └── adapter.rb │ └── jobs.rb ├── active_job │ ├── errors │ │ ├── query_error.rb │ │ ├── invalid_operation.rb │ │ └── job_not_found_error.rb │ ├── failed.rb │ ├── execution_error.rb │ ├── job_proxy.rb │ ├── queues.rb │ ├── executing.rb │ ├── querying.rb │ ├── queue.rb │ └── queue_adapters │ │ └── solid_queue_ext │ │ ├── recurring_tasks.rb │ │ └── workers.rb ├── tasks │ └── mission_control │ │ └── jobs_tasks.rake └── resque │ └── thread_safe_redis.rb ├── docker-compose.yml ├── Rakefile ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── bin ├── setup └── rails ├── config ├── importmap.rb └── routes.rb ├── MIT-LICENSE ├── mission_control-jobs.gemspec └── .github └── workflows └── ruby.yml /test/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.1 2 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/mission_control/jobs/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/javascript/mission_control/jobs/helpers/index.js: -------------------------------------------------------------------------------- 1 | export * from "helpers/debounce_helpers" 2 | -------------------------------------------------------------------------------- /docs/images/workers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/workers.png -------------------------------------------------------------------------------- /test/dummy/test/fixtures/posts.yml: -------------------------------------------------------------------------------- 1 | hello_world: 2 | title: Hello World 3 | body: This is my first post 4 | -------------------------------------------------------------------------------- /docs/images/single-job.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/single-job.png -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/dummy_job.rb: -------------------------------------------------------------------------------- 1 | class DummyJob < ApplicationJob 2 | def perform(value = nil) 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /docs/images/default-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/default-queue.png -------------------------------------------------------------------------------- /docs/images/queues-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/queues-simple.png -------------------------------------------------------------------------------- /docs/images/single-worker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/single-worker.png -------------------------------------------------------------------------------- /lib/mission_control/jobs/version.rb: -------------------------------------------------------------------------------- 1 | module MissionControl 2 | module Jobs 3 | VERSION = "0.3.3" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /docs/images/in-progress-jobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/in-progress-jobs.png -------------------------------------------------------------------------------- /docs/images/queues-multiple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/queues-multiple.png -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /docs/images/failed-jobs-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frenkel/mission_control-jobs/main/docs/images/failed-jobs-simple.png -------------------------------------------------------------------------------- /test/dummy/app/jobs/with_pagination_dummy_job.rb: -------------------------------------------------------------------------------- 1 | class WithPaginationDummyJob < DummyJob 2 | self.default_page_size = 2 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/errors/resource_not_found.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::Errors::ResourceNotFound < StandardError 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/dummy_reloaded_job.rb: -------------------------------------------------------------------------------- 1 | class DummyReloadedJob < ApplicationJob 2 | def perform(value = nil) 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/pause_job.rb: -------------------------------------------------------------------------------- 1 | class PauseJob < ApplicationJob 2 | def perform(time = 1) 3 | sleep(time) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/active_job/errors/query_error.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob 2 | module Errors 3 | class QueryError < StandardError; end 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/errors/incompatible_adapter.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::Errors::IncompatibleAdapter < StandardError 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/with_pagination_failing_job.rb: -------------------------------------------------------------------------------- 1 | class WithPaginationFailingJob < FailingJob 2 | self.default_page_size = 2 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/active_job/errors/invalid_operation.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob 2 | module Errors 3 | class InvalidOperation < StandardError; end 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/mission_control/jobs_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :mission_control_jobs do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link mission_control_jobs_manifest.js 4 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/failing_job.rb: -------------------------------------------------------------------------------- 1 | class FailingJob < ApplicationJob 2 | def perform(value = nil) 3 | raise "This always fails!" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/mission_control/jobs/current.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::Current < ActiveSupport::CurrentAttributes 2 | attribute :application, :server 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root to: redirect("/jobs") 3 | 4 | mount MissionControl::Jobs::Engine => "/jobs" 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/failing_reloaded_job.rb: -------------------------------------------------------------------------------- 1 | class FailingReloadedJob < ApplicationJob 2 | def perform(value = nil) 3 | raise "This always fails!" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_raw_data.html.erb: -------------------------------------------------------------------------------- 1 |

Raw data

2 |
3 |   <%= JSON.pretty_generate(job.raw_data.without("backtrace")) %>
4 | 
5 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/workers/_raw_data.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Raw data

4 |
5 |   <%= JSON.pretty_generate(worker.raw_data) %>
6 | 
7 | -------------------------------------------------------------------------------- /lib/active_job/failed.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::Failed 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | attr_accessor :last_execution_error, :failed_at 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/helpers/mission_control/jobs/dates_helper.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::DatesHelper 2 | def formatted_time(time) 3 | time.strftime("%Y-%m-%d %H:%M:%S.%3N") 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/workers/_configuration.html.erb: -------------------------------------------------------------------------------- 1 |

Configuration

2 |
3 | <%= JSON.pretty_generate(worker.configuration) %>
4 | 
5 | 6 |
7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | redis: {} 3 | 4 | services: 5 | redis: 6 | image: redis:4.0-alpine 7 | ports: 8 | - "6379:6379" 9 | volumes: 10 | - redis:/data 11 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/support/thread_helper.rb: -------------------------------------------------------------------------------- 1 | module ThreadHelper 2 | def sleep_to_force_race_condition 3 | sleep rand / 10.0 # 0.0Xs delays to minimize active delays while ensuring race conditions 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/mission_control/jobs/application_record.rb: -------------------------------------------------------------------------------- 1 | module MissionControl 2 | module Jobs 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/recurring_tasks/_title.html.erb: -------------------------------------------------------------------------------- 1 |

2 |
3 |
4 | <%= recurring_task.id %> 5 |
6 |
7 |

8 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/blocking_job.rb: -------------------------------------------------------------------------------- 1 | class BlockingJob < ApplicationJob 2 | limits_concurrency key: ->(*args) { "exclusive" } 3 | 4 | def perform(pause = nil) 5 | sleep(pause) if pause 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/failing_post_job.rb: -------------------------------------------------------------------------------- 1 | class FailingPostJob < ApplicationJob 2 | def perform(post, published_at, author: "Jorge", price: 0.0) 3 | raise "This always fails! Post: #{post.inspect}" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" 9 | -------------------------------------------------------------------------------- /test/mission_control/jobs_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::JobsTest < ActiveSupport::TestCase 4 | test "it has a version number" do 5 | assert MissionControl::Jobs::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/finished/_job.html.erb: -------------------------------------------------------------------------------- 1 | <%= link_to job.queue_name, application_queue_path(@application, job.queue) %> 2 |
<%= formatted_time(job.finished_at) %>
3 | -------------------------------------------------------------------------------- /app/javascript/mission_control/jobs/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/turbo-rails" 3 | import "controllers" 4 | import "helpers" 5 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/blocked/_actions.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= button_to "Dispatch", application_job_dispatch_path(@application, job.job_id), class: "button is-warning is-light mr-0" %> 3 |
4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /test/dummy/db/*.sqlite3 7 | /test/dummy/db/*.sqlite3-* 8 | /test/dummy/log/*.log* 9 | /test/dummy/storage/ 10 | /test/dummy/tmp/ 11 | .DS_Store 12 | .ruby-gemset 13 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/auto_retrying_job.rb: -------------------------------------------------------------------------------- 1 | class AutoRetryingJob < ApplicationJob 2 | class RandomError < StandardError; end 3 | 4 | retry_on RandomError, attempts: 3, wait: 0.1.seconds 5 | 6 | def perform 7 | raise RandomError 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20221018092331_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :posts do |t| 4 | t.string :title 5 | t.text :body 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/assets/stylesheets/mission_control/jobs/forms.css: -------------------------------------------------------------------------------- 1 | /* Hide the HTML datalist wonky arrow in Safari and Chrome, since we add it via bulma. */ 2 | [list]::-webkit-calendar-picker-indicator { 3 | opacity: 0; 4 | } 5 | 6 | a[disabled] { 7 | pointer-events: none; 8 | } 9 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20240909204134_add_name_to_processes.solid_queue.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_queue (originally 20240811173327) 2 | class AddNameToProcesses < ActiveRecord::Migration[7.1] 3 | def change 4 | add_column :solid_queue_processes, :name, :string 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/javascript/mission_control/jobs/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /app/javascript/mission_control/jobs/helpers/debounce_helpers.js: -------------------------------------------------------------------------------- 1 | export function debounce(fn, delay = 10) { 2 | let timeoutId = null 3 | 4 | return (...args) => { 5 | const callback = () => fn.apply(this, args) 6 | clearTimeout(timeoutId) 7 | timeoutId = setTimeout(callback, delay) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/workers/index.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: "Workers", section: :workers) %> 2 | 3 | <%= render "mission_control/jobs/workers/workers_page", workers_page: @workers_page %> 4 | 5 | <%= render "mission_control/jobs/shared/pagination_toolbar", page: @workers_page, filter_param: {} %> 6 | 7 | -------------------------------------------------------------------------------- /app/assets/config/mission_control_jobs_manifest.js: -------------------------------------------------------------------------------- 1 | //= link_directory ../stylesheets/mission_control/jobs .css 2 | //= link_directory ../../javascript/mission_control/jobs .js 3 | //= link_directory ../../javascript/mission_control/jobs/controllers .js 4 | //= link_directory ../../javascript/mission_control/jobs/helpers .js 5 | -------------------------------------------------------------------------------- /app/views/layouts/mission_control/jobs/_navigation.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 9 |
10 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/console/context.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::Console::Context 2 | mattr_accessor :jobs_server 3 | 4 | def evaluate(*) 5 | if MissionControl::Jobs::Current.server 6 | MissionControl::Jobs::Current.server.activating { super } 7 | else 8 | super 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Want to disable a check for a specific case? To disable checks inline: 2 | # http://docs.rubocop.org/en/latest/configuration/#disabling-cops-within-source-code 3 | 4 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 5 | 6 | AllCops: 7 | TargetRubyVersion: 3.0 8 | Exclude: 9 | - "test/dummy/db/schema.rb" 10 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/scheduled/_actions.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= button_to "Discard", application_job_discard_path(@application, job.job_id), class: "button is-danger is-light mr-0", 3 | form: { data: { turbo_confirm: "This will delete the job and can't be undone. Are you sure?" } } %> 4 |
5 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/workers/_title.html.erb: -------------------------------------------------------------------------------- 1 |

2 |
3 |
4 | <%= "Worker #{worker.id} — #{worker.name}" %> 5 |
6 | 7 |
8 | <%= worker.hostname %> 9 |
10 |
11 |

12 | -------------------------------------------------------------------------------- /app/views/layouts/mission_control/jobs/_flash.html.erb: -------------------------------------------------------------------------------- 1 | <% flash.each do |name, message| -%> 2 | <% message_class = { notice: "is-success", alert: "is-danger" }[name.to_sym] %> 3 | 4 |
5 |
6 | <%= message %> 7 |
8 |
9 | <% end -%> 10 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | class_attribute :invocations 3 | 4 | queue_as :default 5 | 6 | before_perform do |job| 7 | job.class.invocations ||= [] 8 | job.class.invocations << Invocation.new(arguments) 9 | end 10 | 11 | Invocation = Struct.new(:arguments) 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in mission_control-jobs.gemspec. 5 | gemspec 6 | 7 | gem "solid_queue", github: "rails/solid_queue", branch: "improve-recurring-tasks-config" 8 | 9 | gem "capybara", github: "teamcapybara/capybara" 10 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/failed/_job.html.erb: -------------------------------------------------------------------------------- 1 | 2 | <%= link_to failed_job_error(job), application_job_path(@application, job.job_id, anchor: "error") %> 3 |
<%= formatted_time(job.failed_at) %>
4 | 5 | 6 | <%= render "mission_control/jobs/jobs/failed/actions", job: job %> 7 | 8 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20240909204136_change_solid_queue_recurring_tasks_static_to_not_null.solid_queue.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_queue (originally 20240819165045) 2 | class ChangeSolidQueueRecurringTasksStaticToNotNull < ActiveRecord::Migration[7.1] 3 | def change 4 | change_column_null :solid_queue_recurring_tasks, :static, false, true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/mission_control/jobs/base_application_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::BaseApplicationControllerTest < ActiveSupport::TestCase 4 | test "engine's ApplicationController inherits from host's ApplicationController by default" do 5 | assert MissionControl::Jobs::ApplicationController < ApplicationController 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/blocked/_job.html.erb: -------------------------------------------------------------------------------- 1 | <%= link_to job.queue_name, application_queue_path(@application, job.queue) %> 2 |
<%= job.blocked_by %>
3 | <%= formatted_time(job.blocked_until) %> 4 | 5 | <%= render "mission_control/jobs/jobs/blocked/actions", job: job %> 6 | 7 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/applications.rb: -------------------------------------------------------------------------------- 1 | # A container to register applications 2 | class MissionControl::Jobs::Applications < MissionControl::Jobs::IdentifiedElements 3 | def add(name, queue_adapters_by_name = {}) 4 | self << MissionControl::Jobs::Application.new(name: name).tap do |application| 5 | application.add_servers(queue_adapters_by_name) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/identified_by_name.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::IdentifiedByName 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | attr_reader :name 6 | alias to_s name 7 | end 8 | 9 | def initialize(name:) 10 | @name = name.to_s 11 | end 12 | 13 | def id 14 | name.parameterize 15 | end 16 | 17 | alias to_param id 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/helpers/mission_control/jobs/application_helper.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs 2 | module ApplicationHelper 3 | # Explicit helper inclusion because ApplicationController inherits from the host app. 4 | # 5 | # We can't rely on +config.action_controller.include_all_helpers = true+ in the host app. 6 | include DatesHelper, JobsHelper, NavigationHelper, InterfaceHelper 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/shared/_jobs.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%= render partial: "mission_control/jobs/shared/job", collection: jobs %> 12 | 13 | 14 |
Job
15 | -------------------------------------------------------------------------------- /lib/active_job/execution_error.rb: -------------------------------------------------------------------------------- 1 | # Information about a given error when executing a job. 2 | # 3 | # It's attached to failed jobs at +ActiveJob::Base#last_execution_error+. 4 | ActiveJob::ExecutionError = Struct.new(:error_class, :message, :backtrace, keyword_init: true) do 5 | def to_s 6 | "ERROR #{error_class}: #{message}\n#{backtrace&.collect { |line| "\t#{line}" }&.join("\n")}" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/active_job/job_proxy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveJob::JobProxyTest < ActiveSupport::TestCase 4 | test "serializes and deserializes jobs respecting their original class" do 5 | job = DummyJob.new(123) 6 | job_proxy = ActiveJob::JobProxy.new(job.serialize) 7 | assert_instance_of DummyJob, ActiveJob::Base.deserialize(job_proxy.serialize) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | cd "$(dirname "${BASH_SOURCE[0]}")" 4 | 5 | if docker compose version &> /dev/null; then 6 | DOCKER_COMPOSE_CMD="docker compose" 7 | else 8 | DOCKER_COMPOSE_CMD="docker-compose" 9 | fi 10 | 11 | $DOCKER_COMPOSE_CMD up -d --remove-orphans 12 | $DOCKER_COMPOSE_CMD ps 13 | 14 | bundle 15 | 16 | echo "Creating databases..." 17 | 18 | rails db:reset 19 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | 8 | namespace :app do 9 | namespace :db do 10 | task :seed do 11 | Rails.application.load_seed 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/concerns/mission_control/jobs/job_scoped.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::JobScoped 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before_action :set_job 6 | end 7 | 8 | private 9 | def set_job 10 | @job = jobs_relation.find_by_id!(params[:job_id] || params[:id]) 11 | end 12 | 13 | def jobs_relation 14 | raise NotImplementedError 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/controllers/concerns/mission_control/jobs/queue_scoped.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::QueueScoped 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before_action :set_queue 6 | end 7 | 8 | private 9 | def set_queue 10 | @queue = ActiveJob.queues[params[:queue_id]] or raise MissionControl::Jobs::Errors::ResourceNotFound, "Queue '#{params[:queue_id]}' not found" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/scheduled/_job.html.erb: -------------------------------------------------------------------------------- 1 | <%= link_to job.queue_name, application_queue_path(@application, job.queue) %> 2 | 3 | <%= formatted_time(job.scheduled_at) %> 4 | <% if job_delayed?(job) %> 5 |
delayed
6 | <% end %> 7 | 8 | 9 | <%= render "mission_control/jobs/jobs/scheduled/actions", job: job %> 10 | 11 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/show.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: "Job #{@job.job_id}", section: navigation_section_for_status(@job.status)) %> 2 | 3 | <%= render "mission_control/jobs/jobs/title", job: @job %> 4 | <%= render "mission_control/jobs/jobs/general_information", job: @job %> 5 | <%= render "mission_control/jobs/jobs/error_information", job: @job %> 6 | <%= render "mission_control/jobs/jobs/raw_data", job: @job %> 7 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/queues/index.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: "Queues", section: :queues) %> 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%= render partial: "mission_control/jobs/queues/queue", collection: @queues %> 14 | 15 | 16 |
QueuePending jobs
17 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/queues/_actions.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if queue.active? %> 3 | <%= button_to "Pause", application_queue_pause_path(@application, queue.name), method: :post, class: "button is-success is-light mr-0" %> 4 | <% else %> 5 | <%= button_to "Resume", application_queue_pause_path(@application, queue.name), method: :delete, class: "button is-warning is-light mr-0" %> 6 | <% end %> 7 |
8 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/retries_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::RetriesController < MissionControl::Jobs::ApplicationController 2 | include MissionControl::Jobs::JobScoped 3 | 4 | def create 5 | @job.retry 6 | redirect_to application_jobs_url(@application, :failed), notice: "Retried job with id #{@job.job_id}" 7 | end 8 | 9 | private 10 | def jobs_relation 11 | ActiveJob.jobs.failed 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/failed/_actions.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= button_to "Discard", application_job_discard_path(@application, job.job_id), class: "button is-danger is-light mr-0", 3 | form: { data: { turbo_confirm: "This will delete the job and can't be undone. Are you sure?" } } %> 4 | <%= button_to "Retry", application_job_retry_path(@application, job.job_id), class: "button is-warning is-light mr-0" %> 5 |
6 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/in_progress/_job.html.erb: -------------------------------------------------------------------------------- 1 | <%= link_to job.queue_name, application_queue_path(@application, job.queue) %> 2 | 3 | <% if job.worker_id %> 4 | <%= link_to "worker #{job.worker_id}", application_worker_path(@application, job.worker_id) %> 5 | <% else %> 6 | — 7 | <% end %> 8 | 9 |
<%= job.started_at ? formatted_time(job.started_at) : "(Finished)" %>
10 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/workers/_workers_page.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <%= render partial: "mission_control/jobs/workers/worker", collection: workers_page.records %> 13 | 14 | 15 |
WorkerHostnameJobsLast heartbeat
16 | -------------------------------------------------------------------------------- /lib/active_job/errors/job_not_found_error.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob 2 | module Errors 3 | class JobNotFoundError < StandardError 4 | attr_reader :job_relation 5 | 6 | def initialize(job_or_job_id, job_relation) 7 | @job_relation = job_relation 8 | 9 | job_id = job_or_job_id.is_a?(ActiveJob::Base) ? job_or_job_id.job_id : job_or_job_id 10 | super "Job with id '#{job_id}' not found" 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/dispatches_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::DispatchesController < MissionControl::Jobs::ApplicationController 2 | include MissionControl::Jobs::JobScoped 3 | 4 | def create 5 | @job.dispatch 6 | redirect_to application_jobs_url(@application, :blocked), notice: "Dispatched job with id #{@job.job_id}" 7 | end 8 | 9 | private 10 | def jobs_relation 11 | ApplicationJob.jobs.blocked 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /test/support/job_queues_helper.rb: -------------------------------------------------------------------------------- 1 | module JobQueuesHelper 2 | extend ActiveSupport::Concern 3 | 4 | def DynamicQueueJob(queue_name) 5 | Class.new(ApplicationJob) do 6 | def self.name 7 | "DynamicQueueJob" 8 | end 9 | 10 | queue_as queue_name 11 | 12 | def perform 13 | end 14 | end 15 | end 16 | 17 | def create_queues(*names) 18 | names.each do |name| 19 | DynamicQueueJob(name).perform_later 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/bulk_retries_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::BulkRetriesController < MissionControl::Jobs::ApplicationController 2 | include MissionControl::Jobs::FailedJobsBulkOperations 3 | 4 | def create 5 | jobs_to_retry_count = bulk_limited_filtered_failed_jobs.count 6 | bulk_limited_filtered_failed_jobs.retry_all 7 | 8 | redirect_to application_jobs_url(@application, :failed), notice: "Retried #{jobs_to_retry_count} jobs" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/queues/pauses_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::Queues::PausesController < MissionControl::Jobs::ApplicationController 2 | include MissionControl::Jobs::QueueScoped 3 | 4 | def create 5 | @queue.pause 6 | 7 | redirect_back fallback_location: application_queues_url(@application) 8 | end 9 | 10 | def destroy 11 | @queue.resume 12 | 13 | redirect_back fallback_location: application_queues_url(@application) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/shared/_pagination_toolbar.html.erb: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/application_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::ApplicationController < MissionControl::Jobs.base_controller_class.constantize 2 | layout "mission_control/jobs/application" 3 | 4 | include MissionControl::Jobs::ApplicationScoped, MissionControl::Jobs::NotFoundRedirections 5 | include MissionControl::Jobs::AdapterFeatures 6 | 7 | private 8 | def default_url_options 9 | { server_id: MissionControl::Jobs::Current.server } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/console/connect_to.rb: -------------------------------------------------------------------------------- 1 | require "irb/command" 2 | 3 | module MissionControl::Jobs::Console 4 | class ConnectTo < IRB::Command::Base 5 | category "Mission control jobs" 6 | description "Connect to a job server" 7 | 8 | def execute(server_locator) 9 | server = MissionControl::Jobs::Server.from_global_id(server_locator) 10 | MissionControl::Jobs::Current.server = server 11 | 12 | puts "Connected to #{server_locator}" 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/queues_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::QueuesController < MissionControl::Jobs::ApplicationController 2 | before_action :set_queue, only: :show 3 | 4 | def index 5 | @queues = ActiveJob.queues.sort_by(&:name) 6 | end 7 | 8 | def show 9 | @jobs_page = MissionControl::Jobs::Page.new(@queue.jobs, page: params[:page].to_i) 10 | end 11 | 12 | private 13 | def set_queue 14 | @queue = ActiveJob.queues[params[:id]] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_jobs_page.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% attribute_names_for_job_status(jobs_status).each do |attribute| %> 6 | 7 | <% end %> 8 | 9 | 10 | 11 | <%= render partial: "mission_control/jobs/jobs/job", collection: jobs_page.records %> 12 | 13 |
Job<%= attribute %>
14 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/queues/_queue.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= link_to queue.name, application_queue_path(@application, queue.name) %> 4 | <% if queue.paused? %> 5 | Paused 6 | <% end %> 7 | 8 | 9 | <%= queue.size %> 10 | 11 | 12 | <% if queue_pausing_supported? %> 13 | <%= render "mission_control/jobs/queues/actions", queue: queue %> 14 | <% end %> 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/recurring_tasks/index.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: "Recurring tasks", section: :recurring_tasks) %> 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | <%= render partial: "mission_control/jobs/recurring_tasks/recurring_task", collection: @recurring_tasks %> 15 | 16 |
JobScheduleLast enqueued at
17 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_job.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= link_to job_title(job), application_job_path(@application, job.job_id) %> 4 | 5 | <% if job.serialized_arguments.present? %> 6 |
<%= job_arguments(job) %>
7 | <% end %> 8 | 9 |
Enqueued <%= formatted_time(job.enqueued_at.to_datetime) %>
10 | 11 | 12 | <%= render "mission_control/jobs/jobs/#{jobs_status}/job", job: job %> 13 | 14 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/server/workers.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::Server::Workers 2 | def workers_relation 3 | MissionControl::Jobs::WorkersRelation.new(queue_adapter: queue_adapter) 4 | end 5 | 6 | def find_worker(worker_id) 7 | if worker = queue_adapter.find_worker(worker_id) 8 | MissionControl::Jobs::Worker.new(queue_adapter: queue_adapter, **worker) 9 | else 10 | raise MissionControl::Jobs::Errors::ResourceNotFound, "Worker with id '#{worker_id}' not found" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/concerns/mission_control/jobs/job_filters.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::JobFilters 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before_action :set_filters 6 | 7 | helper_method :active_filters? 8 | end 9 | 10 | private 11 | def set_filters 12 | @job_filters = { job_class_name: params.dig(:filter, :job_class_name).presence, queue_name: params.dig(:filter, :queue_name).presence }.compact 13 | end 14 | 15 | def active_filters? 16 | @job_filters.any? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/views/layouts/mission_control/jobs/_application_selection.html.erb: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/queues/_job.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= link_to application_job_path(@application, job.job_id, filter: { queue_name: job.queue }) do %> 4 | <%= job_title(job) %> 5 | <% end %> 6 |
Enqueued on <%= formatted_time(job.enqueued_at.to_datetime) %>
7 | 8 | 9 | <% if job.serialized_arguments.present? %> 10 | 11 | <%= job_arguments(job) %> 12 | 13 | <% end %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/system/list_queues_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class ListQueuesTest < ApplicationSystemTestCase 4 | setup do 5 | create_queues *10.times.collect { |index| "queue_#{index}" } 6 | end 7 | 8 | test "list queues sorted by name" do 9 | visit queues_path 10 | 11 | assert_equal 10, queue_row_elements.length 12 | queue_row_elements.each.with_index do |queue_element, index| 13 | within queue_element do 14 | assert_text "queue_#{index}" 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/models/mission_control/jobs/worker.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::Worker 2 | include ActiveModel::Model 3 | 4 | attr_accessor :id, :name, :hostname, :last_heartbeat_at, :configuration, :raw_data 5 | 6 | def initialize(queue_adapter: ActiveJob::Base.queue_adapter, **kwargs) 7 | @queue_adapter = queue_adapter 8 | super(**kwargs) 9 | end 10 | 11 | def jobs 12 | @jobs ||= ActiveJob::JobsRelation.new(queue_adapter: queue_adapter).in_progress.where(worker_id: id) 13 | end 14 | 15 | private 16 | attr_reader :queue_adapter 17 | end 18 | -------------------------------------------------------------------------------- /test/system/show_queue_job_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class ShowQueueJobsTest < ApplicationSystemTestCase 4 | setup do 5 | DummyJob.queue_as :queue_1 6 | 10.times { |index| DummyJob.perform_later("dummy-arg-#{index}") } 7 | 8 | visit queues_path 9 | end 10 | 11 | test "click on a queue job to see its details" do 12 | click_on "queue_1" 13 | 14 | within_job_row /dummy-arg-2/ do 15 | click_on "DummyJob" 16 | end 17 | 18 | assert_text /arguments\s*dummy-arg-2/i 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/layouts/mission_control/jobs/application_selection/_applications.html.erb: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/identified_elements.rb: -------------------------------------------------------------------------------- 1 | # A collection of elements offering a Hash-like access based on 2 | # their +id+. 3 | class MissionControl::Jobs::IdentifiedElements 4 | include Enumerable 5 | 6 | delegate :[], :empty?, to: :elements 7 | delegate :each, :last, :length, to: :to_a 8 | 9 | def initialize 10 | @elements = HashWithIndifferentAccess.new 11 | end 12 | 13 | def <<(item) 14 | @elements[item.id] = item 15 | end 16 | 17 | def to_a 18 | @elements.values 19 | end 20 | 21 | private 22 | attr_reader :elements 23 | end 24 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/queues/_queue_title.html.erb: -------------------------------------------------------------------------------- 1 |

2 |
3 |
4 | <%= queue.name %> 5 | <% if queue.paused? %> 6 | Paused 7 | <% end %> 8 |
9 | <% if queue_pausing_supported? %> 10 |
11 | <%= render "mission_control/jobs/queues/actions", queue: queue %> 12 |
13 | <% end %> 14 |
15 |

16 | 17 |

<%= pluralize queue.size, "pending job" %>

18 | -------------------------------------------------------------------------------- /app/models/mission_control/jobs/recurring_task.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::RecurringTask 2 | include ActiveModel::Model 3 | 4 | attr_accessor :id, :job_class_name, :command, :arguments, :schedule, :last_enqueued_at, :queue_name, :priority 5 | 6 | def initialize(queue_adapter: ActiveJob::Base.queue_adapter, **kwargs) 7 | @queue_adapter = queue_adapter 8 | super(**kwargs) 9 | end 10 | 11 | def jobs 12 | ActiveJob::JobsRelation.new(queue_adapter: queue_adapter).where(recurring_task_id: id) 13 | end 14 | 15 | private 16 | attr_reader :queue_adapter 17 | end 18 | -------------------------------------------------------------------------------- /test/mission_control/jobs/workers_relation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::WorkersTest < ActiveSupport::TestCase 4 | setup do 5 | queue_adapter = ActiveJob::QueueAdapters::SolidQueueExt 6 | @workers_relation = MissionControl::Jobs::WorkersRelation.new(queue_adapter: queue_adapter) 7 | end 8 | 9 | test "set limit and offset" do 10 | assert_equal 0, @workers_relation.offset_value 11 | 12 | workers = @workers_relation.offset(10).limit(20) 13 | 14 | assert_equal 10, workers.offset_value 15 | assert_equal 20, workers.limit_value 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/system/list_failed_jobs_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class ListFailedJobsTest < ApplicationSystemTestCase 4 | setup do 5 | 10.times { |index| FailingJob.perform_later(index) } 6 | perform_enqueued_jobs 7 | 8 | visit jobs_path(:failed) 9 | end 10 | 11 | test "view the failed jobs" do 12 | assert_equal 10, job_row_elements.length 13 | job_row_elements.each.with_index do |job_element, index| 14 | within job_element do 15 | assert_text "FailingJob" 16 | assert_text "#{index}" 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/discards_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::DiscardsController < MissionControl::Jobs::ApplicationController 2 | include MissionControl::Jobs::JobScoped 3 | 4 | def create 5 | @job.discard 6 | redirect_to redirect_location, notice: "Discarded job with id #{@job.job_id}" 7 | end 8 | 9 | private 10 | def jobs_relation 11 | ActiveJob.jobs.failed 12 | end 13 | 14 | def redirect_location 15 | status = @job.status.presence_in(supported_job_statuses) || :failed 16 | application_jobs_url(@application, status) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | pin "application", to: "mission_control/jobs/application.js", preload: true 2 | pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true 3 | pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true 4 | pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true 5 | pin_all_from MissionControl::Jobs::Engine.root.join("app/javascript/mission_control/jobs/controllers"), under: "controllers", to: "mission_control/jobs/controllers" 6 | pin_all_from MissionControl::Jobs::Engine.root.join("app/javascript/mission_control/jobs/helpers"), under: "helpers", to: "mission_control/jobs/helpers" 7 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/solid_queue_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveJob::QueueAdapters::SolidQueueTest < ActiveSupport::TestCase 4 | include ActiveJob::QueueAdapters::AdapterTesting 5 | include DispatchJobs 6 | 7 | setup do 8 | SolidQueue.logger = ActiveSupport::Logger.new(nil) 9 | end 10 | 11 | private 12 | def queue_adapter 13 | :solid_queue 14 | end 15 | 16 | def perform_enqueued_jobs 17 | worker = SolidQueue::Worker.new(queues: "*", threads: 1, polling_interval: 0.01) 18 | worker.mode = :inline 19 | worker.start 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/mission_control/jobs/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 15 | -------------------------------------------------------------------------------- /app/views/layouts/mission_control/jobs/application_selection/_servers.html.erb: -------------------------------------------------------------------------------- 1 | <% if application.servers.many? %> 2 | 15 | <% end %> 16 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/workers/show.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: "Worker #{@worker.id}", section: :workers) %> 2 | 3 | <%= render "mission_control/jobs/workers/title", worker: @worker %> 4 | <%= render "mission_control/jobs/workers/configuration", worker: @worker %> 5 | 6 | <% if @worker.jobs.empty? %> 7 | <%= blank_status_notice "This worker is idle" %> 8 | <% else %> 9 |

Running <%= pluralize @worker.jobs.size, "job" %>

10 | 11 | <%= render "mission_control/jobs/shared/jobs", jobs: @worker.jobs %> 12 | <% end %> 13 | 14 | <%= render "mission_control/jobs/workers/raw_data", worker: @worker %> 15 | -------------------------------------------------------------------------------- /app/javascript/mission_control/jobs/controllers/form_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import { debounce } from "helpers" 3 | 4 | export default class extends Controller { 5 | static values = { 6 | debounceTimeout: { type: Number, default: 300 } 7 | } 8 | 9 | initialize() { 10 | this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue) 11 | } 12 | 13 | submit(event) { 14 | const form = event.target.form || event.target.closest("form") 15 | if (form) form.requestSubmit() 16 | } 17 | 18 | debouncedSubmit(event) { 19 | this.submit(event) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/server/recurring_tasks.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::Server::RecurringTasks 2 | def recurring_tasks 3 | queue_adapter.recurring_tasks.collect do |task| 4 | MissionControl::Jobs::RecurringTask.new(queue_adapter: queue_adapter, **task) 5 | end.sort_by(&:id) 6 | end 7 | 8 | def find_recurring_task(task_id) 9 | if task = queue_adapter.find_recurring_task(task_id) 10 | MissionControl::Jobs::RecurringTask.new(queue_adapter: queue_adapter, **task) 11 | else 12 | raise MissionControl::Jobs::Errors::ResourceNotFound, "Recurring task with id '#{task_id}' not found" 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/queue_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveJob::QueueAdapters::QueueAdapterTest < ActiveSupport::TestCase 4 | include ResqueHelper 5 | 6 | test "changing current resque adapter is thread-safe" do 7 | 2.times.collect { ActiveJob::QueueAdapters::ResqueAdapter.new }.flat_map do |new_adapter| 8 | 20.times.collect do 9 | Thread.new do 10 | ActiveJob::Base.current_queue_adapter = new_adapter 11 | sleep_to_force_race_condition 12 | assert_equal new_adapter, ActiveJob::Base.queue_adapter 13 | end 14 | end 15 | end.flatten.each(&:join) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/javascript/mission_control/jobs/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap under controllers/* 2 | 3 | import { application } from "controllers/application" 4 | 5 | // Eager load all controllers defined in the import map under controllers/**/*_controller 6 | import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" 7 | eagerLoadControllersFrom("controllers", application) 8 | 9 | // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) 10 | // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" 11 | // lazyLoadControllersFrom("controllers", application) 12 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/application.rb: -------------------------------------------------------------------------------- 1 | # An application containing backend jobs servers 2 | class MissionControl::Jobs::Application 3 | include MissionControl::Jobs::IdentifiedByName 4 | 5 | attr_reader :servers 6 | 7 | def initialize(name:) 8 | super 9 | @servers = MissionControl::Jobs::IdentifiedElements.new 10 | end 11 | 12 | def add_servers(queue_adapters_by_name) 13 | queue_adapters_by_name.each do |name, queue_adapter| 14 | adapter, cleaner = queue_adapter 15 | 16 | servers << MissionControl::Jobs::Server.new(name: name.to_s, queue_adapter: adapter, 17 | backtrace_cleaner: cleaner, application: self) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/dummy/config/solid_queue.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | workers: 3 | - queues: background 4 | threads: 3 5 | - queues: default 6 | threads: 5 7 | dispatchers: 8 | - polling_interval: 1 9 | batch_size: 500 10 | recurring_tasks: 11 | periodic_dummy_job: 12 | class: DummyJob 13 | args: 42 14 | schedule: every second 15 | periodic_pause_job: 16 | class: PauseJob 17 | args: 10 18 | schedule: every minute 19 | - recurring_tasks: 20 | periodic_dummy_job_bis: 21 | class: DummyJob 22 | args: 24 23 | schedule: every second 24 | 25 | development: 26 | <<: *default 27 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20240806160416_create_recurring_tasks.solid_queue.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_queue (originally 20240719134516) 2 | class CreateRecurringTasks < ActiveRecord::Migration[7.1] 3 | def change 4 | create_table :solid_queue_recurring_tasks do |t| 5 | t.string :key, null: false, index: { unique: true } 6 | t.string :schedule, null: false 7 | t.string :command, limit: 2048 8 | t.string :class_name 9 | t.text :arguments 10 | 11 | t.string :queue_name 12 | t.integer :priority, default: 0 13 | 14 | t.boolean :static, default: true, index: true 15 | 16 | t.text :description 17 | 18 | t.timestamps 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/support/jobs_helper.rb: -------------------------------------------------------------------------------- 1 | module JobsHelper 2 | extend ActiveSupport::Concern 3 | 4 | def assert_job_proxy(expected_class, job) 5 | assert_instance_of ActiveJob::JobProxy, job 6 | assert_equal expected_class.to_s, job.job_class_name 7 | end 8 | 9 | def within_job_server(app_id, server: nil, &block) 10 | application = MissionControl::Jobs.applications[app_id] 11 | server = (server && application.servers[server]) || application.servers.first 12 | raise "No jobs server for application with id #{app_id} (#{server})" if server.nil? 13 | server.activating &block 14 | end 15 | 16 | def default_job_server 17 | MissionControl::Jobs.applications.first.servers.first 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/resque_helper.rb: -------------------------------------------------------------------------------- 1 | module ResqueHelper 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | setup do 6 | ActiveJob::Base.queue_adapter = :resque 7 | @old_data_store = Resque.redis 8 | end 9 | 10 | teardown do 11 | Resque.redis = @old_data_store 12 | end 13 | end 14 | 15 | private 16 | def current_resque_redis 17 | redis_from_resque_data_store Resque.redis 18 | end 19 | 20 | def redis_from_resque_data_store(data_store) 21 | data_store.instance_variable_get("@redis") 22 | end 23 | 24 | def create_resque_redis(name) 25 | Redis::Namespace.new name, redis: @old_data_store.instance_variable_get("@redis") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/helpers/mission_control/jobs/interface_helper.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::InterfaceHelper 2 | def blank_status_notice(message) 3 | tag.div message, class: "mt-6 has-text-centered is-size-3 has-text-grey" 4 | end 5 | 6 | def blank_status_emoji(status) 7 | case status.to_s 8 | when "failed", "blocked" then "😌" 9 | else "" 10 | end 11 | end 12 | 13 | def modifier_for_status(status) 14 | case status.to_s 15 | when "failed" then "is-danger" 16 | when "blocked" then "is-warning" 17 | when "finished" then "is-success" 18 | when "scheduled" then "is-info" 19 | when "in_progress" then "is-primary" 20 | else "is-primary is-light" 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_title.html.erb: -------------------------------------------------------------------------------- 1 |

2 |
3 |
4 | <%= job_title(job) %> 5 |
<%= job.status %>
6 |
7 |
8 | <% if job.failed? %> 9 | <%= render "mission_control/jobs/jobs/failed/actions", job: job %> 10 | <% end %> 11 | <% if job.blocked? %> 12 | <%= render "mission_control/jobs/jobs/blocked/actions", job: job %> 13 | <% end %> 14 | <% if job.scheduled? %> 15 | <%= render "mission_control/jobs/jobs/scheduled/actions", job: job %> 16 | <% end %> 17 |
18 |
19 |

20 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/failed/_backtrace_toggle.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (application:, job:) %> 2 | 3 |
4 |
5 | 13 |
14 |
-------------------------------------------------------------------------------- /test/dummy/db/migrate/20240909204135_make_name_not_null.solid_queue.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from solid_queue (originally 20240813160053) 2 | class MakeNameNotNull < ActiveRecord::Migration[7.1] 3 | def up 4 | SolidQueue::Process.where(name: nil).find_each do |process| 5 | process.name ||= [ process.kind.downcase, SecureRandom.hex(10) ].join("-") 6 | process.save! 7 | end 8 | 9 | change_column :solid_queue_processes, :name, :string, null: false 10 | add_index :solid_queue_processes, [ :name, :supervisor_id ], unique: true 11 | end 12 | 13 | def down 14 | remove_index :solid_queue_processes, [ :name, :supervisor_id ] 15 | change_column :solid_queue_processes, :name, :string, null: true 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/adapter_testing/dispatch_jobs.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::AdapterTesting::DispatchJobs 2 | extend ActiveSupport::Testing::Declarative 3 | 4 | test "dispatch blocked job immediately" do 5 | 10.times { |index| BlockingJob.perform_later(index * 0.1.seconds) } 6 | 7 | # Given, there is one pending and the others are blocked 8 | pending_jobs = ActiveJob.jobs.pending 9 | assert_equal 1, pending_jobs.size 10 | blocked_jobs = ActiveJob.jobs.blocked 11 | assert_equal 9, blocked_jobs.size 12 | 13 | blocked_jobs.each(&:dispatch) 14 | 15 | # Then, all blocked jobs are pending 16 | assert_empty blocked_jobs.reload 17 | assert_equal 10, pending_jobs.reload.size 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /test/support/ui_helper.rb: -------------------------------------------------------------------------------- 1 | module UIHelper 2 | def hover_app_selector(and_click:) 3 | find(".application-selector").hover 4 | find(".application-selector .navbar-item", text: and_click).click 5 | end 6 | 7 | def click_on_server_selector(name) 8 | within ".server-selector" do 9 | click_on name 10 | end 11 | end 12 | 13 | def within_queue_row(text, &block) 14 | row = find(".queues .queue", text: text) 15 | within row, &block 16 | end 17 | 18 | def queue_row_elements 19 | all(".queues .queue") 20 | end 21 | 22 | def within_job_row(text, &block) 23 | row = find(".jobs .job", text: text) 24 | within row, &block 25 | end 26 | 27 | def job_row_elements 28 | all(".jobs .job") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/workers/_worker.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= link_to "worker #{worker.id}", application_worker_path(@application, worker.id) %> 4 |
5 | <%= worker.name %> 6 | 7 | <%= worker.hostname %> 8 | 9 | <% worker.jobs.each do |job| %> 10 |
11 | <%= link_to job_title(job), application_job_path(@application, job.job_id) %> 12 | 13 | <% if job.serialized_arguments.present? %> 14 |
<%= job_arguments(job) %>
15 | <% end %> 16 |
17 | <% end %> 18 | 19 | 20 |
<%= formatted_time(worker.last_heartbeat_at) %>
21 | 22 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/recurring_tasks/show.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: @recurring_task.id, section: :recurring_tasks) %> 2 | 3 | <%= render "mission_control/jobs/recurring_tasks/title", recurring_task: @recurring_task %> 4 | <%= render "mission_control/jobs/recurring_tasks/general_information", recurring_task: @recurring_task %> 5 | 6 | <% if @jobs_page.empty? %> 7 | <%= blank_status_notice "No jobs found for this recurring task" %> 8 | <% else %> 9 |

<%= pluralize @recurring_task.jobs.count, "job" %>

10 | 11 | <%= render "mission_control/jobs/shared/jobs", jobs: @jobs_page.records %> 12 | 13 | <%= render "mission_control/jobs/shared/pagination_toolbar", page: @jobs_page, filter_param: jobs_filter_param %> 14 | <% end %> 15 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | pool: 10 22 | database: db/test.sqlite3 23 | 24 | production: 25 | <<: *default 26 | database: db/production.sqlite3 27 | -------------------------------------------------------------------------------- /app/controllers/concerns/mission_control/jobs/failed_jobs_bulk_operations.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::FailedJobsBulkOperations 2 | extend ActiveSupport::Concern 3 | 4 | MAX_NUMBER_OF_JOBS_FOR_BULK_OPERATIONS = 3000 5 | 6 | included do 7 | include MissionControl::Jobs::JobFilters 8 | end 9 | 10 | private 11 | # We set a hard limit to prevent problems with the data store (for example, overloading Redis 12 | # or causing replication lag in MySQL). This should be enough for most scenarios. For 13 | # cases where we need to retry a huge sets of jobs, we offer a runbook that uses the API. 14 | def bulk_limited_filtered_failed_jobs 15 | ActiveJob.jobs.failed.where(**@job_filters).limit(MAX_NUMBER_OF_JOBS_FOR_BULK_OPERATIONS) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/queues/show.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: "Queue #{@queue.name}", section: :queues) %> 2 | 3 | <%= render "mission_control/jobs/queues/queue_title", queue: @queue %> 4 | 5 | <% if @jobs_page.empty? %> 6 | <%= blank_status_notice "The queue is empty" %> 7 | <% else %> 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | <%= render partial: "mission_control/jobs/queues/job", collection: @jobs_page.records %> 18 | 19 | 20 |
Job
21 | 22 | <%= render "mission_control/jobs/shared/pagination_toolbar", page: @jobs_page, filter_param: {} %> 23 | <% end %> 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_error_information.html.erb: -------------------------------------------------------------------------------- 1 | <% if job.failed? %> 2 |

Error information

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
Type <%= job.last_execution_error.error_class %>
Message <%= job.last_execution_error.try(:message) || job.last_execution_error.inspect %>
16 | 17 | <% if @server.backtrace_cleaner %> 18 | <%= render "mission_control/jobs/jobs/failed/backtrace_toggle", application: @application, job: job %> 19 | <% end %> 20 | 21 |
<%= failed_job_backtrace(job, @server) %>
22 | <% end %> 23 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/workers_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::WorkersController < MissionControl::Jobs::ApplicationController 2 | before_action :ensure_exposed_workers 3 | 4 | def index 5 | @workers_page = MissionControl::Jobs::Page.new(workers_relation, page: params[:page].to_i) 6 | @workers_count = @workers_page.total_count 7 | end 8 | 9 | def show 10 | @worker = MissionControl::Jobs::Current.server.find_worker(params[:id]) 11 | end 12 | 13 | private 14 | def ensure_exposed_workers 15 | unless workers_exposed? 16 | redirect_to root_url, alert: "This server doesn't expose workers" 17 | end 18 | end 19 | 20 | def workers_relation 21 | MissionControl::Jobs::Current.server.workers_relation 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/console/jobs_help.rb: -------------------------------------------------------------------------------- 1 | require "irb/command" 2 | 3 | module MissionControl::Jobs::Console 4 | class JobsHelp < IRB::Command::Base 5 | category "Mission control jobs" 6 | description "Show help for managing jobs" 7 | 8 | def execute(*) 9 | puts "You are currently connected to #{MissionControl::Jobs::Current.server}" if MissionControl::Jobs::Current.server 10 | 11 | puts "You can connect to a job server with" 12 | puts %( connect_to :\n\n) 13 | 14 | puts "Available job servers:\n" 15 | 16 | MissionControl::Jobs.applications.each do |application| 17 | application.servers.each do |server| 18 | puts " * #{server.to_global_id}" 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/assets/stylesheets/mission_control/jobs/jobs.css: -------------------------------------------------------------------------------- 1 | .filter input { 2 | width: 15rem; 3 | } 4 | 5 | table.jobs { 6 | td { 7 | word-break: break-all; 8 | } 9 | th.job-header{ 10 | width: 30%; 11 | } 12 | th.duration-header { 13 | width: 20%; 14 | } 15 | &.queues th.job-header { 16 | width: 30%; 17 | } 18 | &.failed th.job-header { 19 | width: 35%; 20 | } 21 | &.in_progress th.job-header { 22 | width: 50%; 23 | } 24 | &.blocked th.job-header { 25 | width: 45%; 26 | } 27 | &.scheduled th.job-header { 28 | width: 60%; 29 | } 30 | &.finished th.job-header { 31 | width: 65%; 32 | } 33 | &.workers th.job-header { 34 | width: 40%; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/adapter_testing.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::AdapterTesting 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | include CountJobs, DiscardJobs, FindJobs, JobBatches, QueryJobs, Queues, RetryJobs 6 | 7 | setup do 8 | ActiveJob::Base.queue_adapter = queue_adapter 9 | end 10 | end 11 | 12 | private 13 | # Returns the adapter to test. 14 | # 15 | # Template method to override in child classes. 16 | # 17 | # E.g: +:resque+, +:sidekiq+ 18 | def queue_adapter 19 | raise NotImplementedError 20 | end 21 | 22 | # Perform the jobs in the queue. 23 | # 24 | # Template method to override in child classes. 25 | def perform_enqueued_jobs 26 | raise NotImplementedError 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 4 | driven_by :selenium_chrome_headless, screen_size: [ 1200, 1000 ] 5 | 6 | include UIHelper 7 | 8 | include MissionControl::Jobs::Engine.routes.url_helpers 9 | 10 | def run 11 | # Activate default job server so that setup data before any navigation 12 | # happens is loaded there. 13 | default_job_server.activating do 14 | super 15 | end 16 | end 17 | 18 | # UI tests just use Resque for now 19 | def perform_enqueued_jobs 20 | worker = Resque::Worker.new("*") 21 | worker.work(0.0) 22 | end 23 | end 24 | 25 | Capybara.configure do |config| 26 | config.server = :puma, { Silent: true, Threads: "10:50" } 27 | end 28 | -------------------------------------------------------------------------------- /test/system/list_queue_jobs_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class ListQueueJobsTest < ApplicationSystemTestCase 4 | setup do 5 | DummyJob.queue_as :queue_1 6 | 10.times { |index| DummyJob.perform_later(index) } 7 | 8 | visit queues_path 9 | end 10 | 11 | test "view the jobs in a queue" do 12 | click_on "queue_1" 13 | 14 | assert_equal 10, job_row_elements.length 15 | job_row_elements.each.with_index do |job_element, index| 16 | within job_element do 17 | assert_text "DummyJob" 18 | assert_text "#{index}" 19 | end 20 | end 21 | end 22 | 23 | test "show empty notice when no jobs" do 24 | perform_enqueued_jobs 25 | click_on "queue_1" 26 | assert_text /queue is empty/i 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/bulk_discards_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::BulkDiscardsController < MissionControl::Jobs::ApplicationController 2 | include MissionControl::Jobs::FailedJobsBulkOperations 3 | 4 | def create 5 | jobs_to_discard_count = jobs_to_discard.count 6 | jobs_to_discard.discard_all 7 | 8 | redirect_to application_jobs_url(@application, :failed), notice: "Discarded #{jobs_to_discard_count} jobs" 9 | end 10 | 11 | private 12 | def jobs_to_discard 13 | if active_filters? 14 | bulk_limited_filtered_failed_jobs 15 | else 16 | # we don't want to apply any limit since "discarding all" without parameters can be optimized in the adapter as a much faster operation 17 | ActiveJob.jobs.failed 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/assets/stylesheets/mission_control/jobs/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | -------------------------------------------------------------------------------- /test/mission_control/jobs/application_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::ApplicationTest < ActiveSupport::TestCase 4 | setup do 5 | @application = MissionControl::Jobs::Application.new(name: "BC4") 6 | end 7 | 8 | test "register job servers" do 9 | queue_adapter = ActiveJob::QueueAdapters::ResqueAdapter.new 10 | @application.add_servers chicago: queue_adapter 11 | 12 | server = @application.servers.first 13 | assert_equal "chicago", server.name 14 | assert_equal queue_adapter, server.queue_adapter 15 | end 16 | 17 | test "find job servers by name or slug" do 18 | queue_adapter = ActiveJob::QueueAdapters::ResqueAdapter.new 19 | @application.add_servers "US east 1": queue_adapter 20 | assert_equal queue_adapter, @application.servers["us-east-1"].queue_adapter 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/mission_control/jobs/server_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::ServerTest < ActiveSupport::TestCase 4 | setup do 5 | @application = MissionControl::Jobs.applications[:bc4] 6 | end 7 | 8 | test "activating a queue adapter" do 9 | current_adapter = ActiveJob::Base.queue_adapter 10 | new_adapter = ActiveJob::QueueAdapters::ResqueAdapter.new 11 | server = MissionControl::Jobs::Server.new(name: "resque_chicago", queue_adapter: new_adapter, application: @application) 12 | 13 | assert_equal current_adapter, ActiveJob::Base.queue_adapter 14 | 15 | server.activating do 16 | @executed = true 17 | assert_equal new_adapter, ActiveJob::Base.queue_adapter 18 | end 19 | 20 | assert @executed 21 | assert_equal current_adapter, ActiveJob::Base.queue_adapter 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/mission_control/jobs/applications_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::ApplicationsTest < ActiveSupport::TestCase 4 | setup do 5 | @applications = MissionControl::Jobs::Applications.new 6 | end 7 | 8 | test "register applications with job servers" do 9 | queue_adapter = ActiveJob::QueueAdapters::ResqueAdapter.new 10 | @applications.add :bc4, chicago: queue_adapter 11 | 12 | server = @applications.first.servers.first 13 | assert_equal "chicago", server.name 14 | assert_equal queue_adapter, server.queue_adapter 15 | end 16 | 17 | test "find applications by their id" do 18 | queue_adapter = ActiveJob::QueueAdapters::ResqueAdapter.new 19 | @applications.add "Basecamp 4", chicago: queue_adapter 20 | 21 | assert_equal "Basecamp 4", @applications["basecamp-4"].name 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/system/pause_queues_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class PauseQueuesTest < ApplicationSystemTestCase 4 | setup do 5 | create_queues "queue_1", "queue_2" 6 | 7 | visit queues_path 8 | end 9 | 10 | test "pause and resume a queue from the list of queues" do 11 | within_queue_row "queue_2" do 12 | assert_no_text "Resume" 13 | click_on "Pause" 14 | assert_text "Resume" 15 | 16 | click_on "Resume" 17 | assert_no_text "Resume" 18 | assert_text "Pause" 19 | end 20 | end 21 | 22 | test "pause and resume a queue in the details screen" do 23 | click_on "queue_2" 24 | 25 | assert_no_text "Resume" 26 | click_on "Pause" 27 | assert_text "Resume" 28 | 29 | click_on "Resume" 30 | assert_no_text "Resume" 31 | assert_text "Pause" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/recurring_tasks/_recurring_task.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= link_to recurring_task.id, application_recurring_task_path(@application, recurring_task.id) %> 4 | 5 | 6 | <% if recurring_task.job_class_name.present? %> 7 | <%= recurring_task.job_class_name %> 8 | 9 | <% if recurring_task.arguments.present? %> 10 |
<%= recurring_task.arguments.join(",") %>
11 | <% end %> 12 | <% elsif recurring_task.command.present? %> 13 |
<%= recurring_task.command %>
14 | <% end %> 15 | 16 | <%= recurring_task.schedule %> 17 |
<%= recurring_task.last_enqueued_at ? formatted_time(recurring_task.last_enqueued_at) : "Never" %>
18 | 19 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/recurring_tasks_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::RecurringTasksController < MissionControl::Jobs::ApplicationController 2 | before_action :ensure_supported_recurring_tasks 3 | before_action :set_recurring_task, only: :show 4 | 5 | def index 6 | @recurring_tasks = MissionControl::Jobs::Current.server.recurring_tasks 7 | end 8 | 9 | def show 10 | @jobs_page = MissionControl::Jobs::Page.new(@recurring_task.jobs, page: params[:page].to_i) 11 | end 12 | 13 | private 14 | def ensure_supported_recurring_tasks 15 | unless recurring_tasks_supported? 16 | redirect_to root_url, alert: "This server doesn't support recurring tasks" 17 | end 18 | end 19 | 20 | def set_recurring_task 21 | @recurring_task = MissionControl::Jobs::Current.server.find_recurring_task(params[:id]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_toolbar.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if active_filters? %> 3 | 4 | <%= jobs_count %> jobs found 5 | 6 | <% end %> 7 | 8 | <% if jobs_status.failed? %> 9 | <% target = active_filters? ? "selection" : "all" %> 10 | 11 | <%= button_to "Discard #{target}", application_bulk_discards_path(@application, **jobs_filter_param), 12 | method: :post, disabled: jobs_count == 0, class: "button is-danger is-light", 13 | form: { data: { turbo_confirm: "This will delete #{jobs_count} jobs and can't be undone. Are you sure?" } } %> 14 | <%= button_to "Retry #{target}", application_bulk_retries_path(@application, **jobs_filter_param), 15 | method: :post, disabled: jobs_count == 0, 16 | class: "button is-warning is-light mr-0" %> 17 | <% end %> 18 |
19 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/shared/_job.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= link_to application_job_path(@application, job.job_id, filter: { queue_name: job.queue }) do %> 4 | <%= job_title(job) %> 5 | <% end %> 6 |
Enqueued on <%= formatted_time(job.enqueued_at.to_datetime) %>
7 | 8 | 9 | <% if job.serialized_arguments.present? %> 10 | 11 | <%= job_arguments(job) %> 12 | 13 | <% end %> 14 | 15 | 16 | 17 |
18 | <% if job.started_at %> 19 | Running since <%= formatted_time(job.started_at) %> 20 | <% elsif job.finished_at %> 21 | Finished on <%= formatted_time(job.finished_at) %> 22 | <% else %> 23 | Pending 24 | <% end %> 25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /app/controllers/concerns/mission_control/jobs/adapter_features.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::AdapterFeatures 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | helper_method :supported_job_statuses, :queue_pausing_supported?, :workers_exposed?, :recurring_tasks_supported? 6 | end 7 | 8 | private 9 | def supported_job_statuses 10 | MissionControl::Jobs::Current.server.queue_adapter.supported_job_statuses & ActiveJob::JobsRelation::STATUSES 11 | end 12 | 13 | def queue_pausing_supported? 14 | MissionControl::Jobs::Current.server.queue_adapter.supports_queue_pausing? 15 | end 16 | 17 | def workers_exposed? 18 | MissionControl::Jobs::Current.server.queue_adapter.exposes_workers? 19 | end 20 | 21 | def recurring_tasks_supported? 22 | MissionControl::Jobs::Current.server.queue_adapter.supports_recurring_tasks? 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_job/job_proxy.rb: -------------------------------------------------------------------------------- 1 | # A proxy for managing jobs without having to load the corresponding 2 | # job class. 3 | # 4 | # This is useful for managing jobs without having the job classes 5 | # present in the code base. 6 | class ActiveJob::JobProxy < ActiveJob::Base 7 | class UnsupportedError < StandardError; end 8 | 9 | attr_reader :job_class_name 10 | 11 | def initialize(job_data) 12 | super 13 | @job_class_name = job_data["job_class"] 14 | deserialize(job_data) 15 | end 16 | 17 | def serialize 18 | super.tap do |json| 19 | json["job_class"] = @job_class_name 20 | end 21 | end 22 | 23 | def perform_now 24 | raise UnsupportedError, "A JobProxy doesn't support immediate execution, only enqueuing." 25 | end 26 | 27 | ActiveJob::JobsRelation::STATUSES.each do |status| 28 | define_method "#{status}?" do 29 | self.status == status 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/active_job/queues.rb: -------------------------------------------------------------------------------- 1 | # An enumerable collection of queues that supports direct access to queues by name. 2 | # 3 | # queue_1 = ApplicationJob::Queue.new("queue_1") 4 | # queue_2 = ApplicationJob::Queue.new("queue_2") 5 | # queues = ApplicationJob::Queues.new([queue_1, queue_2]) 6 | # 7 | # queues[:queue_1] #=> queue_1 8 | # queues[:queue_2] #=> queue_2 9 | # queues.to_a #=> [ queue_1, queue_2 ] # Enumerable 10 | # 11 | # See +ActiveJob::Queue+. 12 | class ActiveJob::Queues 13 | include Enumerable 14 | 15 | delegate :each, to: :values 16 | delegate :values, to: :queues_by_name, private: true 17 | delegate :[], :size, :length, :to_s, :inspect, to: :queues_by_name 18 | 19 | def initialize(queues) 20 | @queues_by_name = queues.index_by(&:name).with_indifferent_access 21 | end 22 | 23 | def to_h 24 | queues_by_name.dup 25 | end 26 | 27 | private 28 | attr_reader :queues_by_name 29 | end 30 | -------------------------------------------------------------------------------- /test/mission_control/erb_inline_styles_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "better_html" 5 | require "better_html/parser" 6 | require "better_html/tree/tag" 7 | 8 | class MissionControl::ErbInlineStylesTest < ActiveSupport::TestCase 9 | ERB_GLOB = Rails.root.join( 10 | "..", "..", "app", "views", "**", "{*.htm,*.html,*.htm.erb,*.html.erb,*.html+*.erb}" 11 | ) 12 | 13 | Dir[ERB_GLOB].each do |filename| 14 | pathname = Pathname.new(filename).relative_path_from(Rails.root) 15 | 16 | test "No inline styles in /#{pathname.relative_path_from('../..')}" do 17 | buffer = Parser::Source::Buffer.new("") 18 | buffer.source = File.read(filename) 19 | parser = BetterHtml::Parser.new(buffer) 20 | 21 | parser.nodes_with_type(:tag).each do |tag_node| 22 | tag = BetterHtml::Tree::Tag.from_node(tag_node) 23 | assert_nil tag.attributes["style"] 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/index.html.erb: -------------------------------------------------------------------------------- 1 | <% navigation(title: "#{jobs_status.titleize} jobs", section: "#{jobs_status}_jobs".to_sym) %> 2 | 3 | <% if @jobs_page.empty? && !active_filters? %> 4 | <%= blank_status_notice "There are no #{jobs_status.dasherize} jobs #{blank_status_emoji(jobs_status)}" %> 5 | <% else %> 6 |
7 | <%= render "mission_control/jobs/jobs/filters", job_class_names: @job_class_names, queue_names: @queue_names %> 8 | <%= render "mission_control/jobs/jobs/toolbar", jobs_count: @jobs_count %> 9 |
10 | 11 | <% if @jobs_page.empty? %> 12 | <%= blank_status_notice "No #{jobs_status.dasherize} jobs found with the given filters" %> 13 | <% else %> 14 | <%= render "mission_control/jobs/jobs/jobs_page", jobs_page: @jobs_page %> 15 | 16 | <%= render "mission_control/jobs/shared/pagination_toolbar", page: @jobs_page, filter_param: jobs_filter_param %> 17 | <% end %> 18 | <% end %> 19 | 20 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/server.rb: -------------------------------------------------------------------------------- 1 | require "active_job/queue_adapter" 2 | 3 | class MissionControl::Jobs::Server 4 | include MissionControl::Jobs::IdentifiedByName 5 | include Serializable, RecurringTasks, Workers 6 | 7 | attr_reader :name, :queue_adapter, :application, :backtrace_cleaner 8 | 9 | def initialize(name:, queue_adapter:, application:, backtrace_cleaner: nil) 10 | super(name: name) 11 | @queue_adapter = queue_adapter 12 | @application = application 13 | @backtrace_cleaner = backtrace_cleaner || MissionControl::Jobs.backtrace_cleaner 14 | end 15 | 16 | def activating(&block) 17 | previous_adapter = ActiveJob::Base.current_queue_adapter 18 | ActiveJob::Base.current_queue_adapter = queue_adapter 19 | queue_adapter.activating(&block) 20 | ensure 21 | ActiveJob::Base.current_queue_adapter = previous_adapter 22 | end 23 | 24 | def queue_adapter_name 25 | ActiveJob.adapter_name(queue_adapter).underscore.to_sym 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/views/layouts/mission_control/jobs/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mission control - <%= page_title %> 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | 9 | 10 | 11 | <%= stylesheet_link_tag "mission_control/jobs/application", "data-turbo-track": "reload" %> 12 | <%= javascript_importmap_tags "application", importmap: MissionControl::Jobs.importmap %> 13 | 14 | 15 | 16 |
17 |
18 | <%= render "layouts/mission_control/jobs/application_selection" %> 19 | <%= render "layouts/mission_control/jobs/flash" %> 20 | <%= render "layouts/mission_control/jobs/navigation" %> 21 | <%= yield %> 22 |
23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/resque_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveJob::QueueAdapters::ResqueTest < ActiveSupport::TestCase 4 | include ActiveJob::QueueAdapters::AdapterTesting 5 | 6 | test "supports queue pausing when ResquePauseHelper is defined" do 7 | assert ActiveJob::Base.queue_adapter.supports_queue_pausing? 8 | end 9 | 10 | test "does not support queue pausing when ResquePauseHelper is not defined" do 11 | emulating_resque_pause_gem_absence do 12 | assert_not ActiveJob::Base.queue_adapter.supports_queue_pausing? 13 | end 14 | end 15 | 16 | private 17 | def emulating_resque_pause_gem_absence 18 | helper_const = Object.send(:remove_const, :ResquePauseHelper) 19 | yield 20 | ensure 21 | Object.const_set(:ResquePauseHelper, helper_const) 22 | end 23 | 24 | def queue_adapter 25 | :resque 26 | end 27 | 28 | def perform_enqueued_jobs 29 | @worker ||= Resque::Worker.new("*") 30 | @worker.work(0.0) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/server/serializable.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::Server::Serializable 2 | extend ActiveSupport::Concern 3 | 4 | class_methods do 5 | # Loads a server from a locator string with the format +:+. For example: 6 | # 7 | # bc4:resque_chicago 8 | # 9 | # When the ++ fragment is omitted it will return the first server for the application. 10 | def from_global_id(global_id) 11 | app_id, server_id = global_id.split(":") 12 | 13 | application = MissionControl::Jobs.applications[app_id] or raise MissionControl::Jobs::Errors::ResourceNotFound, "No application with id #{app_id} found" 14 | server = server_id ? application.servers[server_id] : application.servers.first 15 | 16 | server or raise MissionControl::Jobs::Errors::ResourceNotFound, "No server for #{global_id} found" 17 | end 18 | end 19 | 20 | def to_global_id 21 | suffix = ":#{id}" if application.servers.many? 22 | "#{application&.id}#{suffix}" 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mission_control_jobs.rb: -------------------------------------------------------------------------------- 1 | require "resque" 2 | require "resque_pause_helper" 3 | 4 | require "solid_queue" 5 | 6 | Resque.redis = Redis::Namespace.new "#{Rails.env}", redis: Redis.new(host: "localhost", port: 6379) 7 | 8 | SERVERS_BY_APP = { 9 | BC4: %w[ resque_ashburn resque_chicago ], 10 | HEY: %w[ resque solid_queue ] 11 | } 12 | 13 | def redis_connection_for(app, server) 14 | redis_namespace = Redis::Namespace.new "#{app}:#{server}", redis: Resque.redis.instance_variable_get("@redis") 15 | Resque::DataStore.new redis_namespace 16 | end 17 | 18 | SERVERS_BY_APP.each do |app, servers| 19 | queue_adapters_by_name = servers.collect do |server| 20 | queue_adapter = if server.start_with?("resque") 21 | ActiveJob::QueueAdapters::ResqueAdapter.new(redis_connection_for(app, server)) 22 | else 23 | ActiveJob::QueueAdapters::SolidQueueAdapter.new 24 | end 25 | 26 | [ server, queue_adapter ] 27 | end.to_h 28 | 29 | MissionControl::Jobs.applications.add(app, queue_adapters_by_name) 30 | end 31 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/adapter_testing/find_jobs.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::AdapterTesting::FindJobs 2 | extend ActiveSupport::Concern 3 | extend ActiveSupport::Testing::Declarative 4 | 5 | test "find a job by id" do 6 | DummyJob.queue_as(:queue_1) 7 | job = DummyJob.perform_later(1234) 8 | found_job = ActiveJob.queues[:queue_1].jobs.find_by_id(job.job_id) 9 | 10 | assert_job_proxy DummyJob, found_job 11 | assert_equal [ 1234 ], found_job.serialized_arguments 12 | 13 | found_job = ActiveJob.jobs.where(queue_name: :queue_1).find_by_id(job.job_id) 14 | assert_job_proxy DummyJob, found_job 15 | assert_equal [ 1234 ], found_job.serialized_arguments 16 | end 17 | 18 | test "find returns nil when not found" do 19 | assert_nil ActiveJob.jobs.failed.find_by_id("1234-6789") 20 | end 21 | 22 | test "find! raises an error when the job is missing" do 23 | assert_raises ActiveJob::Errors::JobNotFoundError do 24 | ActiveJob.jobs.failed.find_by_id!("1234-6789") 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/system/paginate_jobs_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class PaginateJobsTest < ApplicationSystemTestCase 4 | setup do 5 | 20.times { |index| FailingJob.perform_later(index) } 6 | perform_enqueued_jobs 7 | 8 | visit jobs_path(:failed) 9 | end 10 | 11 | test "paginate failed jobs" do 12 | assert_jobs 0..9 13 | 14 | click_on "Next page" 15 | assert_jobs 10..19 16 | 17 | click_on "Previous page" 18 | assert_jobs 0..9 19 | end 20 | 21 | private 22 | def assert_jobs(range) 23 | expected_indexes = range.to_a 24 | 25 | # Wait for page to load 26 | assert_text /FailingJob\s*#{expected_indexes.first}/i 27 | assert_text /FailingJob\s*#{expected_indexes.last}/i 28 | 29 | assert_equal expected_indexes.length, job_row_elements.length 30 | 31 | job_row_elements.each.with_index do |job_element, index| 32 | within job_element do 33 | assert_text /FailingJob\s*#{expected_indexes[index]}/i 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/resque/thread_safe_redis.rb: -------------------------------------------------------------------------------- 1 | # Set and access +Resque.redis+ in a thread-safe way. 2 | module Resque::ThreadSafeRedis 3 | thread_mattr_accessor :thread_resque_override 4 | 5 | def self.resque_override 6 | self.thread_resque_override ||= ResqueOverride.new 7 | end 8 | 9 | def redis 10 | Resque::ThreadSafeRedis.resque_override.data_store_override || super 11 | end 12 | alias :data_store :redis 13 | 14 | def with_per_thread_redis_override(redis_instance, &block) 15 | Resque::ThreadSafeRedis.resque_override.enable_with(redis_instance, &block) 16 | end 17 | 18 | class ResqueOverride 19 | include Resque 20 | 21 | attr_accessor :data_store_override 22 | 23 | def enable_with(server, &block) 24 | previous_redis, previous_data_store_override = redis, data_store_override 25 | self.redis = server 26 | self.data_store_override = @data_store 27 | 28 | block.call 29 | ensure 30 | self.redis = previous_redis 31 | self.data_store_override = previous_data_store_override 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | MissionControl::Jobs::Engine.routes.draw do 2 | resources :applications, only: [] do 3 | resources :queues, only: [ :index, :show ] do 4 | scope module: :queues do 5 | resource :pause, only: [ :create, :destroy ] 6 | end 7 | end 8 | 9 | resources :jobs, only: :show do 10 | resource :retry, only: :create 11 | resource :discard, only: :create 12 | resource :dispatch, only: :create 13 | 14 | collection do 15 | resource :bulk_retries, only: :create 16 | resource :bulk_discards, only: :create 17 | end 18 | end 19 | 20 | resources :jobs, only: :index, path: ":status/jobs" 21 | 22 | resources :workers, only: [ :index, :show ] 23 | resources :recurring_tasks, only: [ :index, :show ] 24 | end 25 | 26 | # Allow referencing urls without providing an application_id. It will default to the first one. 27 | resources :queues, only: [ :index, :show ] 28 | 29 | resources :jobs, only: :show 30 | resources :jobs, only: :index, path: ":status/jobs" 31 | 32 | root to: "queues#index" 33 | end 34 | -------------------------------------------------------------------------------- /test/active_job/queues_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveJob::QueuesTest < ActiveSupport::TestCase 4 | test "enumerable methods" do 5 | queues_list = 10.times.collect { |index| create_queue "queue_#{index}" } 6 | queues = ActiveJob::Queues.new(queues_list) 7 | 8 | assert_equal queues_list, queues.to_a 9 | assert_equal 10, queues.count 10 | end 11 | 12 | test "direct access by name" do 13 | queue_1 = create_queue "queue_1" 14 | queue_2 = create_queue "queue_2" 15 | queues = ActiveJob::Queues.new([ queue_1, queue_2 ]) 16 | 17 | assert_equal queue_1, queues[:queue_1] 18 | assert_equal queue_2, queues["queue_2"] 19 | end 20 | 21 | test "convert to hash" do 22 | queue_1 = create_queue "queue_1" 23 | queue_2 = create_queue "queue_2" 24 | queues = ActiveJob::Queues.new([ queue_1, queue_2 ]) 25 | 26 | expected_hash = { "queue_1" => queue_1, "queue_2" => queue_2 } 27 | assert_equal expected_hash, queues.to_h 28 | end 29 | 30 | private 31 | def create_queue(name) 32 | ActiveJob::Queue.new(name) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/recurring_tasks/_general_information.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <% if recurring_task.job_class_name.present? %> 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <% elsif recurring_task.command.present? %> 14 | 15 | 16 | 17 | 18 | <% end %> 19 | 20 | 21 | 22 | 23 | 24 | <% if recurring_task.queue_name.present? %> 25 | 26 | 27 | 28 | 29 | <% end %> 30 | <% if recurring_task.priority.present? %> 31 | 32 | 33 | 34 | 35 | <% end %> 36 | 37 |
Job class<%= recurring_task.job_class_name %>
Arguments
<%= recurring_task.arguments.join(",") %>
Command
<%= recurring_task.command %>
Schedule<%= recurring_task.schedule %>
Queue<%= recurring_task.queue_name %>
Priority<%= recurring_task.priority %>
38 | -------------------------------------------------------------------------------- /app/controllers/mission_control/jobs/jobs_controller.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::JobsController < MissionControl::Jobs::ApplicationController 2 | include MissionControl::Jobs::JobScoped, MissionControl::Jobs::JobFilters 3 | 4 | skip_before_action :set_job, only: :index 5 | 6 | def index 7 | @job_class_names = jobs_with_status.job_class_names 8 | @queue_names = ActiveJob.queues.map(&:name) 9 | 10 | @jobs_page = MissionControl::Jobs::Page.new(filtered_jobs_with_status, page: params[:page].to_i) 11 | @jobs_count = @jobs_page.total_count 12 | end 13 | 14 | def show 15 | end 16 | 17 | private 18 | 19 | def jobs_relation 20 | filtered_jobs 21 | end 22 | 23 | def filtered_jobs_with_status 24 | filtered_jobs.with_status(jobs_status) 25 | end 26 | 27 | def jobs_with_status 28 | ActiveJob.jobs.with_status(jobs_status) 29 | end 30 | 31 | def filtered_jobs 32 | ActiveJob.jobs.where(**@job_filters) 33 | end 34 | 35 | helper_method :jobs_status 36 | 37 | def jobs_status 38 | params[:status].presence&.inquiry 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 37signals, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | require "sprockets/railtie" 5 | 6 | require "resque" 7 | require "solid_queue" 8 | 9 | # Require the gems listed in Gemfile, including any gems 10 | # you've limited to :test, :development, or :production. 11 | Bundler.require(*Rails.groups) 12 | require "mission_control/jobs" 13 | 14 | 15 | module Dummy 16 | class Application < Rails::Application 17 | config.load_defaults Rails::VERSION::STRING.to_f 18 | 19 | # For compatibility with applications that use this config 20 | config.action_controller.include_all_helpers = true 21 | 22 | # Configuration for the application, engines, and railties goes here. 23 | # 24 | # These settings can be overridden in specific environments using the files 25 | # in config/environments, which are processed later. 26 | # 27 | # config.time_zone = "Central Time (US & Canada)" 28 | # config.eager_load_paths << Rails.root.join("extras") 29 | 30 | # Mission Control supported adapters 31 | config.mission_control.jobs.adapters = [ :resque, :solid_queue ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/active_job/executing.rb: -------------------------------------------------------------------------------- 1 | # TODO: These (or a version of them) should be moved to +ActiveJob::Core+ 2 | # and related concerns when upstreamed. 3 | module ActiveJob::Executing 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | attr_accessor :raw_data, :position, :finished_at, :blocked_by, :blocked_until, :worker_id, :started_at, :status 8 | attr_reader :serialized_arguments 9 | 10 | thread_cattr_accessor :current_queue_adapter 11 | end 12 | 13 | class_methods do 14 | def queue_adapter 15 | ActiveJob::Base.current_queue_adapter || super 16 | end 17 | end 18 | 19 | def retry 20 | ActiveJob.jobs.failed.retry_job(self) 21 | end 22 | 23 | def discard 24 | jobs_relation_for_discarding.discard_job(self) 25 | end 26 | 27 | def dispatch 28 | ActiveJob.jobs.blocked.dispatch_job(self) 29 | end 30 | 31 | private 32 | def jobs_relation_for_discarding 33 | case status 34 | when :failed then ActiveJob.jobs.failed 35 | when :pending then ActiveJob.jobs.pending.where(queue_name: queue_name) 36 | else 37 | ActiveJob.jobs 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/mission_control/jobs.rb: -------------------------------------------------------------------------------- 1 | require "mission_control/jobs/version" 2 | require "mission_control/jobs/engine" 3 | 4 | require "zeitwerk" 5 | 6 | loader = Zeitwerk::Loader.new 7 | loader.inflector = Zeitwerk::GemInflector.new(__FILE__) 8 | loader.push_dir(File.expand_path("..", __dir__)) 9 | loader.ignore("#{File.expand_path("..", __dir__)}/resque") 10 | loader.setup 11 | 12 | module MissionControl 13 | module Jobs 14 | mattr_accessor :adapters, default: Set.new 15 | mattr_accessor :applications, default: MissionControl::Jobs::Applications.new 16 | mattr_accessor :base_controller_class, default: "::ApplicationController" 17 | mattr_accessor :delay_between_bulk_operation_batches, default: 0 18 | mattr_accessor :logger, default: ActiveSupport::Logger.new(nil) 19 | mattr_accessor :internal_query_count_limit, default: 500_000 # Hard limit to keep unlimited count queries fast enough 20 | mattr_accessor :show_console_help, default: true 21 | mattr_accessor :scheduled_job_delay_threshold, default: 1.minute 22 | mattr_accessor :importmap, default: Importmap::Map.new 23 | mattr_accessor :backtrace_cleaner 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /app/controllers/concerns/mission_control/jobs/not_found_redirections.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::NotFoundRedirections 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | rescue_from(ActiveJob::Errors::JobNotFoundError) do |error| 6 | redirect_to best_location_for_job_relation(error.job_relation), alert: error.message 7 | end 8 | 9 | rescue_from(MissionControl::Jobs::Errors::ResourceNotFound) do |error| 10 | redirect_to best_location_for_resource_not_found_error(error), alert: error.message 11 | end 12 | end 13 | 14 | private 15 | def best_location_for_job_relation(job_relation) 16 | case 17 | when job_relation.failed? 18 | application_jobs_path(@application, :failed) 19 | when job_relation.queue_name.present? 20 | application_queue_path(@application, job_relation.queue_name) 21 | else 22 | root_path 23 | end 24 | end 25 | 26 | def best_location_for_resource_not_found_error(error) 27 | if error.message.match?(/recurring task/i) 28 | application_recurring_tasks_path(@application) 29 | else 30 | root_url 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /app/models/mission_control/jobs/page.rb: -------------------------------------------------------------------------------- 1 | class MissionControl::Jobs::Page 2 | DEFAULT_PAGE_SIZE = 10 3 | 4 | attr_reader :records, :index, :page_size 5 | 6 | def initialize(relation, page: 1, page_size: DEFAULT_PAGE_SIZE) 7 | @relation = relation 8 | @page_size = page_size 9 | @index = [ page, 1 ].max 10 | end 11 | 12 | def records 13 | @relation.limit(page_size).offset(offset) 14 | end 15 | 16 | def first? 17 | index == 1 18 | end 19 | 20 | def last? 21 | index == pages_count || empty? || records.empty? 22 | end 23 | 24 | def empty? 25 | total_count == 0 26 | end 27 | 28 | def previous_index 29 | [ index - 1, 1 ].max 30 | end 31 | 32 | def next_index 33 | pages_count ? [ index + 1, pages_count ].min : index + 1 34 | end 35 | 36 | def pages_count 37 | (total_count.to_f / page_size).ceil unless total_count.infinite? 38 | end 39 | 40 | def total_count 41 | @total_count ||= @relation.count # Potentially expensive when filtering a lot of records, with the adapter in charge of doing the filtering in memory 42 | end 43 | 44 | private 45 | def offset 46 | (index - 1) * page_size 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/active_job/querying.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::Querying 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | # ActiveJob will use pagination internally when fetching relations of jobs. This 6 | # parameter sets the max amount of jobs to fetch in each data store query. 7 | class_attribute :default_page_size, default: 1000 8 | end 9 | 10 | class_methods do 11 | # Returns the list of queues. 12 | # 13 | # See +ActiveJob::Queues+ 14 | def queues 15 | ActiveJob::Queues.new(fetch_queues) 16 | end 17 | 18 | def jobs 19 | ActiveJob::JobsRelation.new(queue_adapter: queue_adapter, default_page_size: default_page_size) 20 | end 21 | 22 | private 23 | def fetch_queues 24 | queue_adapter.queues.collect do |queue| 25 | ActiveJob::Queue.new(queue[:name], size: queue[:size], active: queue[:active], queue_adapter: queue_adapter) 26 | end 27 | end 28 | end 29 | 30 | def queue 31 | self.class.queues[queue_name] 32 | end 33 | 34 | # Top-level query methods added to `ActiveJob` 35 | module Root 36 | def queues 37 | ActiveJob::Base.queues 38 | end 39 | 40 | def jobs 41 | ActiveJob::Base.jobs 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/controllers/concerns/mission_control/jobs/application_scoped.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::ApplicationScoped 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | before_action :set_application 6 | around_action :activating_job_server 7 | 8 | delegate :applications, to: MissionControl::Jobs 9 | end 10 | 11 | private 12 | def set_application 13 | @application = find_application or raise MissionControl::Jobs::Errors::ResourceNotFound, "Application not found" 14 | MissionControl::Jobs::Current.application = @application 15 | end 16 | 17 | def find_application 18 | if params[:application_id] 19 | applications[params[:application_id]] 20 | else 21 | applications.first 22 | end 23 | end 24 | 25 | def activating_job_server(&block) 26 | @server = find_server or raise MissionControl::Jobs::Errors::ResourceNotFound, "Server not found" 27 | MissionControl::Jobs::Current.server = @server 28 | @server.activating(&block) 29 | end 30 | 31 | def find_server 32 | if params[:server_id] 33 | MissionControl::Jobs::Current.application.servers[params[:server_id]] 34 | else 35 | @application.servers.first 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /lib/active_job/queue.rb: -------------------------------------------------------------------------------- 1 | # A queue of jobs 2 | class ActiveJob::Queue 3 | attr_reader :name 4 | 5 | def initialize(name, size: nil, active: nil, queue_adapter: ActiveJob::Base.queue_adapter) 6 | @name = name 7 | @queue_adapter = queue_adapter 8 | 9 | @size = size 10 | @active = active 11 | end 12 | 13 | def size 14 | @size ||= queue_adapter.queue_size(name) 15 | end 16 | 17 | alias length size 18 | 19 | def clear 20 | queue_adapter.clear_queue(name) 21 | end 22 | 23 | def empty? 24 | size == 0 25 | end 26 | 27 | def pause 28 | queue_adapter.pause_queue(name) 29 | end 30 | 31 | def resume 32 | queue_adapter.resume_queue(name) 33 | end 34 | 35 | def paused? 36 | !active? 37 | end 38 | 39 | def active? 40 | return @active unless @active.nil? 41 | @active = !queue_adapter.queue_paused?(name) 42 | end 43 | 44 | # Return an +ActiveJob::JobsRelation+ with the pending jobs in the queue. 45 | def jobs 46 | ActiveJob::JobsRelation.new(queue_adapter: queue_adapter).pending.where(queue_name: name) 47 | end 48 | 49 | def reload 50 | @active = @size = nil 51 | self 52 | end 53 | 54 | def id 55 | name.parameterize 56 | end 57 | 58 | alias to_param id 59 | 60 | private 61 | attr_reader :queue_adapter 62 | end 63 | -------------------------------------------------------------------------------- /test/mission_control/jobs/server/serializable_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::Server::SerializableTest < ActiveSupport::TestCase 4 | setup do 5 | @bc4_chicago = MissionControl::Jobs.applications[:bc4].servers[:resque_chicago] 6 | @hey = MissionControl::Jobs.applications[:hey].servers[:solid_queue] 7 | end 8 | 9 | test "generate a global id for a server" do 10 | assert_equal "bc4:resque_chicago", @bc4_chicago.to_global_id 11 | assert_equal "hey:solid_queue", @hey.to_global_id 12 | end 13 | 14 | test "locate a server for a global id" do 15 | assert_equal @bc4_chicago, MissionControl::Jobs::Server.from_global_id("bc4:resque_chicago") 16 | assert_equal @hey, MissionControl::Jobs::Server.from_global_id("hey:solid_queue") 17 | end 18 | 19 | test "raise an error when trying to locate a missing server" do 20 | assert_raises MissionControl::Jobs::Errors::ResourceNotFound do 21 | MissionControl::Jobs::Server.from_global_id("bc4:resque_paris") 22 | end 23 | 24 | assert_raises MissionControl::Jobs::Errors::ResourceNotFound do 25 | MissionControl::Jobs::Server.from_global_id("backpack:resque_chicago") 26 | end 27 | 28 | assert_raises MissionControl::Jobs::Errors::ResourceNotFound do 29 | MissionControl::Jobs::Server.from_global_id("backpack") 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_general_information.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 28 | 29 | <% if job.failed? %> 30 | 31 | 32 | 35 | 36 | <% end %> 37 | <% if job.finished_at.present? %> 38 | 39 | 40 | 43 | 44 | <% end %> 45 | <% if job.worker_id.present? %> 46 | 47 | 48 | 51 | 52 | <% end %> 53 | 54 |
Arguments 6 |
7 | <%= job_arguments(job) %> 8 |
9 |
Job id<%= job.job_id %>
Queue 18 |
19 | <%= link_to job.queue_name, application_queue_path(@application, job.queue) %> 20 |
21 |
Enqueued 26 | <%= formatted_time(job.enqueued_at.to_datetime) %> 27 |
Failed 33 | <%= formatted_time(job.failed_at) %> 34 |
Finished at 41 | <%= formatted_time(job.finished_at) %> 42 |
Processed by 49 | <%= link_to "worker #{job.worker_id}", application_worker_path(@application, job.worker_id) %> 50 |
55 | -------------------------------------------------------------------------------- /test/controllers/retries_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::JobsControllerTest < ActionDispatch::IntegrationTest 4 | test "retry job with invalid ID" do 5 | post mission_control_jobs.application_job_retry_url(@application, "unknown_id") 6 | assert_redirected_to mission_control_jobs.application_jobs_url(@application, :failed) 7 | follow_redirect! 8 | 9 | assert_select "article.is-danger", /Job with id 'unknown_id' not found/ 10 | end 11 | 12 | test "retry jobs when there are multiple instances of the same job due to automatic retries" do 13 | travel_to Time.parse("2024-10-30 19:07:10 UTC") do 14 | job = AutoRetryingJob.perform_later 15 | 16 | perform_enqueued_jobs_async 17 | 18 | get mission_control_jobs.application_jobs_url(@application, :failed) 19 | assert_response :ok 20 | 21 | assert_select "tr.job", 1 22 | assert_select "tr.job", /AutoRetryingJob\s+Enqueued 2024-10-30 19:07:1\d\.\d{3}\s+AutoRetryingJob::RandomError/ 23 | 24 | post mission_control_jobs.application_job_retry_url(@application, job.job_id) 25 | assert_redirected_to mission_control_jobs.application_jobs_url(@application, :failed) 26 | follow_redirect! 27 | 28 | assert_select "article.is-danger", text: /Job with id '#{job.job_id}' not found/, count: 0 29 | assert_select "tr.job", 0 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/active_job/queue_adapters/solid_queue_ext/recurring_tasks.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::SolidQueueExt::RecurringTasks 2 | def supports_recurring_tasks? 3 | true 4 | end 5 | 6 | def recurring_tasks 7 | tasks = SolidQueue::RecurringTask.all 8 | last_enqueued_at_times = recurring_task_last_enqueued_at(tasks.map(&:key)) 9 | 10 | tasks.collect do |task| 11 | recurring_task_attributes_from_solid_queue_recurring_task(task).merge \ 12 | last_enqueued_at: last_enqueued_at_times[task.key] 13 | end 14 | end 15 | 16 | def find_recurring_task(task_id) 17 | if task = SolidQueue::RecurringTask.find_by(key: task_id) 18 | recurring_task_attributes_from_solid_queue_recurring_task(task).merge \ 19 | last_enqueued_at: recurring_task_last_enqueued_at(task.key).values&.first 20 | end 21 | end 22 | 23 | private 24 | def recurring_task_attributes_from_solid_queue_recurring_task(task) 25 | { 26 | id: task.key, 27 | job_class_name: task.class_name, 28 | command: task.command, 29 | arguments: task.arguments, 30 | schedule: task.schedule, 31 | queue_name: task.queue_name, 32 | priority: task.priority 33 | } 34 | end 35 | 36 | def recurring_task_last_enqueued_at(task_keys) 37 | SolidQueue::RecurringExecution.where(task_key: task_keys).group(:task_key).maximum(:run_at) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/controllers/workers_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::WorkersControllerTest < ActionDispatch::IntegrationTest 4 | setup do 5 | 2.times { PauseJob.perform_later } 6 | Socket.stubs(:gethostname).returns("my-hostname-123") 7 | end 8 | 9 | test "get workers" do 10 | perform_enqueued_jobs_async(wait: 0) do 11 | worker = @server.workers_relation.first 12 | get mission_control_jobs.application_workers_url(@application) 13 | 14 | assert_select "tr.worker", 1 15 | assert_select "tr.worker", /worker #{worker.id}\s+PID: \d+\s+my-hostname-123\s+PauseJob/ 16 | end 17 | end 18 | 19 | test "paginate workers" do 20 | register_workers(count: 6) 21 | 22 | stub_const(MissionControl::Jobs::Page, :DEFAULT_PAGE_SIZE, 2) do 23 | get mission_control_jobs.application_workers_url(@application) 24 | assert_response :ok 25 | 26 | assert_select "tr.worker", 2 27 | assert_select "nav[aria-label=\"pagination\"]", /1 \/ 3/ 28 | end 29 | end 30 | 31 | test "get worker details" do 32 | perform_enqueued_jobs_async(wait: 0) do 33 | worker = @server.workers_relation.first 34 | 35 | get mission_control_jobs.application_worker_url(@application, worker.id) 36 | assert_response :ok 37 | 38 | assert_select "h1", /Worker #{worker.id} — PID: \d+/ 39 | assert_select "h2", "Running 2 jobs" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/active_job/queue_adapters/solid_queue_ext/workers.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::SolidQueueExt::Workers 2 | def exposes_workers? 3 | true 4 | end 5 | 6 | def fetch_workers(workers_relation) 7 | solid_queue_processes_from_workers_relation(workers_relation).collect do |process| 8 | worker_from_solid_queue_process(process) 9 | end 10 | end 11 | 12 | def count_workers(workers_relation) 13 | solid_queue_processes_from_workers_relation(workers_relation).count 14 | end 15 | 16 | def find_worker(worker_id) 17 | if process = SolidQueue::Process.find_by(id: worker_id) 18 | worker_attributes_from_solid_queue_process(process) 19 | end 20 | end 21 | 22 | private 23 | def solid_queue_processes_from_workers_relation(relation) 24 | SolidQueue::Process.where(kind: "Worker").offset(relation.offset_value).limit(relation.limit_value) 25 | end 26 | 27 | def worker_from_solid_queue_process(process) 28 | MissionControl::Jobs::Worker.new(queue_adapter: self, **worker_attributes_from_solid_queue_process(process)) 29 | end 30 | 31 | def worker_attributes_from_solid_queue_process(process) 32 | { 33 | id: process.id, 34 | name: "PID: #{process.pid}", 35 | hostname: process.hostname, 36 | last_heartbeat_at: process.last_heartbeat_at, 37 | configuration: process.metadata, 38 | raw_data: process.as_json 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/system/change_apps_and_servers_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class ChangeAppsAndServersTest < ApplicationSystemTestCase 4 | include ResqueHelper 5 | 6 | test "switch apps" do 7 | within_job_server "hey" do 8 | DummyJob.queue_as :hey_queue 9 | 10.times { |index| DummyJob.perform_later(index) } 10 | end 11 | 12 | visit queues_path 13 | assert_empty job_row_elements 14 | 15 | hover_app_selector and_click: /hey/i 16 | assert_equal 1, queue_row_elements.length 17 | 18 | click_on "hey_queue" 19 | assert_equal 10, job_row_elements.length 20 | end 21 | 22 | test "switch job servers" do 23 | DummyJob.queue_as :bc4_queue 24 | 25 | within_job_server "bc4", server: "resque_ashburn" do 26 | 5.times { |index| DummyJob.perform_later(index) } 27 | end 28 | 29 | within_job_server "bc4", server: "resque_chicago" do 30 | DummyJob.queue_as :bc4_queue_chicago 31 | 10.times { |index| DummyJob.perform_later(index) } 32 | end 33 | 34 | visit queues_path 35 | click_on "bc4_queue" 36 | assert_equal 5, job_row_elements.length 37 | 38 | click_on_server_selector "resque_chicago" 39 | assert_text 10 40 | click_on "bc4_queue" 41 | assert_equal 10, job_row_elements.length 42 | 43 | click_on_server_selector "resque_ashburn" 44 | assert_text 5 45 | click_on "bc4_queue" 46 | assert_equal 5, job_row_elements.length 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/views/mission_control/jobs/jobs/_filters.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | <%= form_for :filter, url: application_jobs_path(MissionControl::Jobs::Current.application, jobs_status), method: :get, 5 | data: { controller: "form", action: "input->form#debouncedSubmit" } do |form| %> 6 | 7 |
8 | <%= form.text_field :job_class_name, value: @job_filters[:job_class_name], class: "input", list: "job-classes", placeholder: "Filter by job class..." %> 9 |
10 | 11 |
12 | <%= form.text_field :queue_name, value: @job_filters[:queue_name], class: "input", list: "queue-names", placeholder: "Filter by queue name..." %> 13 |
14 | 15 | <%= hidden_field_tag :server_id, MissionControl::Jobs::Current.server.id %> 16 | 17 | 22 | 23 | 28 | <% end %> 29 |
30 | 31 |
32 | <%= link_to "Clear", application_jobs_path(MissionControl::Jobs::Current.application, jobs_status, job_class_name: nil, queue_name: nil), class: "button" %> 33 |
34 |
35 |
36 | -------------------------------------------------------------------------------- /mission_control-jobs.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/mission_control/jobs/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "mission_control-jobs" 5 | spec.version = MissionControl::Jobs::VERSION 6 | spec.authors = [ "Jorge Manrubia" ] 7 | spec.email = [ "jorge@hey.com" ] 8 | spec.homepage = "https://github.com/rails/mission_control-jobs" 9 | spec.summary = "Operational controls for Active Job" 10 | spec.license = "MIT" 11 | 12 | spec.metadata["homepage_uri"] = spec.homepage 13 | spec.metadata["source_code_uri"] = "https://github.com/rails/mission_control-jobs" 14 | 15 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 16 | Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 17 | end 18 | 19 | spec.add_dependency "rails", ">= 7.1" 20 | spec.add_dependency "importmap-rails", ">= 1.2.1" 21 | spec.add_dependency "turbo-rails" 22 | spec.add_dependency "stimulus-rails" 23 | spec.add_dependency "irb", "~> 1.13" 24 | 25 | spec.add_development_dependency "resque" 26 | spec.add_development_dependency "solid_queue", ">= 0.9" 27 | spec.add_development_dependency "selenium-webdriver" 28 | spec.add_development_dependency "resque-pause" 29 | spec.add_development_dependency "mocha" 30 | spec.add_development_dependency "debug" 31 | spec.add_development_dependency "redis" 32 | spec.add_development_dependency "redis-namespace" 33 | spec.add_development_dependency "rubocop", "~> 1.52.0" 34 | spec.add_development_dependency "rubocop-performance" 35 | spec.add_development_dependency "rubocop-rails-omakase" 36 | spec.add_development_dependency "better_html" 37 | spec.add_development_dependency "sprockets-rails" 38 | spec.add_development_dependency "sqlite3" 39 | spec.add_development_dependency "puma" 40 | end 41 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/workers_relation.rb: -------------------------------------------------------------------------------- 1 | # A relation of workers. 2 | # 3 | # Relations are enumerable, so you can use +Enumerable+ methods on them. 4 | # Notice however that using these methods will imply loading all the relation 5 | # in memory, which could introduce performance concerns. 6 | class MissionControl::Jobs::WorkersRelation 7 | include Enumerable 8 | 9 | attr_accessor :offset_value, :limit_value 10 | 11 | delegate :last, :[], :to_s, :reverse, to: :to_a 12 | 13 | ALL_WORKERS_LIMIT = 100_000_000 # When no limit value it defaults to "all workers" 14 | 15 | def initialize(queue_adapter:) 16 | @queue_adapter = queue_adapter 17 | 18 | set_defaults 19 | end 20 | 21 | def offset(offset) 22 | clone_with offset_value: offset 23 | end 24 | 25 | def limit(limit) 26 | clone_with limit_value: limit 27 | end 28 | 29 | def each(&block) 30 | workers.each(&block) 31 | end 32 | 33 | def reload 34 | @count = @workers = nil 35 | self 36 | end 37 | 38 | def count 39 | if loaded? 40 | to_a.length 41 | else 42 | query_count 43 | end 44 | end 45 | 46 | def empty? 47 | count == 0 48 | end 49 | 50 | alias length count 51 | alias size count 52 | 53 | private 54 | def set_defaults 55 | self.offset_value = 0 56 | self.limit_value = ALL_WORKERS_LIMIT 57 | end 58 | 59 | def workers 60 | @workers ||= @queue_adapter.fetch_workers(self) 61 | end 62 | 63 | def query_count 64 | @count ||= @queue_adapter.count_workers(self) 65 | end 66 | 67 | def loaded? 68 | !@workers.nil? 69 | end 70 | 71 | def clone_with(**properties) 72 | dup.reload.tap do |relation| 73 | properties.each do |key, value| 74 | relation.send("#{key}=", value) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Build 9 | 10 | on: [push, pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | rubocop: 17 | name: Rubocop 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | - name: Setup Ruby and install gems 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: 3.3 26 | bundler-cache: true 27 | - name: Run rubocop 28 | run: | 29 | bundle exec rubocop --parallel 30 | 31 | test: 32 | name: Tests 33 | runs-on: ubuntu-latest 34 | env: 35 | CI: true 36 | BUNDLE_GITHUB__COM: x-access-token:${{ secrets.GH_TOKEN }} 37 | strategy: 38 | matrix: 39 | ruby-version: ['3.0', '3.1', '3.2', '3.3'] 40 | services: 41 | redis: 42 | image: redis:4.0-alpine 43 | ports: 44 | - 6379:6379 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Set up Ruby 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: ${{ matrix.ruby-version }} 51 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 52 | - name: Prepare database 53 | run: bin/rails db:create db:schema:load 54 | - name: Run tests 55 | run: bin/rails test 56 | - name: Run system tests 57 | run: bin/rails app:test:system 58 | 59 | 60 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/active_job/jobs_relation_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveJob::JobsRelationTest < ActiveSupport::TestCase 4 | setup do 5 | @jobs = ActiveJob::JobsRelation.new 6 | end 7 | 8 | test "pass job class names" do 9 | assert_nil @jobs.job_class_name 10 | assert "SomeJob", @jobs.where(job_class_name: "SomeJob").job_class_name 11 | end 12 | 13 | test "filter by pending status" do 14 | assert @jobs.pending.pending? 15 | assert_not @jobs.failed.pending? 16 | end 17 | 18 | test "filter by failed status" do 19 | assert_not @jobs.pending.failed? 20 | assert @jobs.failed.failed? 21 | end 22 | 23 | test "set limit and offset" do 24 | assert_equal 0, @jobs.offset_value 25 | 26 | jobs = @jobs.offset(10).limit(20) 27 | assert_equal 10, jobs.offset_value 28 | assert_equal 20, jobs.limit_value 29 | end 30 | 31 | test "set job class and queue" do 32 | jobs = @jobs.where(job_class_name: "MyJob") 33 | assert_equal "MyJob", jobs.job_class_name 34 | 35 | # Supports concatenation without overriding exising properties 36 | jobs = jobs.where(queue_name: "my_queue") 37 | assert_equal "my_queue", jobs.queue_name 38 | assert_equal "MyJob", jobs.job_class_name 39 | end 40 | 41 | test "caches the fetched set of jobs" do 42 | ActiveJob::Base.queue_adapter.expects(:fetch_jobs).twice.returns([ :job_1, :job_2 ], []) 43 | ActiveJob::Base.queue_adapter.expects(:supports_job_filter?).at_least_once.returns(true) 44 | 45 | jobs = @jobs.where(queue_name: "my_queue") 46 | 47 | 5.times do 48 | assert_equal [ :job_1, :job_2 ], jobs.to_a 49 | end 50 | end 51 | 52 | test "caches the count of jobs" do 53 | ActiveJob::Base.queue_adapter.expects(:jobs_count).once.returns(2) 54 | ActiveJob::Base.queue_adapter.expects(:supports_job_filter?).at_least_once.returns(true) 55 | 56 | jobs = @jobs.where(queue_name: "my_queue") 57 | 58 | 3.times do 59 | assert_equal 2, jobs.count 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/helpers/mission_control/jobs/navigation_helper.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::NavigationHelper 2 | attr_reader :page_title, :current_section 3 | 4 | def navigation_sections 5 | { queues: [ "Queues", application_queues_path(@application) ] }.tap do |sections| 6 | supported_job_statuses.without(:pending).each do |status| 7 | sections[navigation_section_for_status(status)] = [ "#{status.to_s.titleize} jobs (#{jobs_count_with_status(status)})", application_jobs_path(@application, status) ] 8 | end 9 | 10 | sections[:workers] = [ "Workers", application_workers_path(@application) ] if workers_exposed? 11 | sections[:recurring_tasks] = [ "Recurring tasks", application_recurring_tasks_path(@application) ] if recurring_tasks_supported? 12 | end 13 | end 14 | 15 | def navigation_section_for_status(status) 16 | if status.nil? || status == :pending 17 | :queues 18 | else 19 | "#{status}_jobs".to_sym 20 | end 21 | end 22 | 23 | def navigation(title: nil, section: nil) 24 | @page_title = title 25 | @current_section = section 26 | end 27 | 28 | def selected_application?(application) 29 | MissionControl::Jobs::Current.application.name == application.name 30 | end 31 | 32 | def selectable_applications 33 | MissionControl::Jobs.applications.reject { |app| selected_application?(app) } 34 | end 35 | 36 | def selected_server?(server) 37 | MissionControl::Jobs::Current.server.name == server.name 38 | end 39 | 40 | def jobs_filter_param 41 | if @job_filters&.any? 42 | { filter: @job_filters } 43 | else 44 | {} 45 | end 46 | end 47 | 48 | def jobs_count_with_status(status) 49 | count = ActiveJob.jobs.with_status(status).count 50 | if count.infinite? 51 | "..." 52 | else 53 | number_to_human(count, 54 | format: "%n%u", 55 | units: { 56 | thousand: "K", 57 | million: "M", 58 | billion: "B", 59 | trillion: "T", 60 | quadrillion: "Q" 61 | }) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/system/retry_jobs_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class RetryJobsTest < ApplicationSystemTestCase 4 | setup do 5 | 4.times { |index| FailingJob.set(queue: "queue_1").perform_later("failing-arg-#{index}") } 6 | 3.times { |index| FailingReloadedJob.set(queue: "queue_2").perform_later("failing-reloaded-arg-#{4 + index}") } 7 | 2.times { |index| FailingJob.set(queue: "queue_2").perform_later("failing-arg-#{7 + index}") } 8 | perform_enqueued_jobs 9 | perform_enqueued_jobs 10 | 11 | visit jobs_path(:failed) 12 | end 13 | 14 | test "retry all failed jobs" do 15 | assert_equal 9, job_row_elements.length 16 | 17 | click_on "Retry all" 18 | 19 | assert_text "Retried 9 jobs" 20 | assert_empty job_row_elements 21 | end 22 | 23 | test "retry a single job" do 24 | assert_equal 9, job_row_elements.length 25 | expected_job_id = ActiveJob.jobs.failed[2].job_id 26 | 27 | within_job_row "failing-arg-2" do 28 | click_on "Retry" 29 | end 30 | 31 | assert_text "Retried job with id #{expected_job_id}" 32 | 33 | assert_equal 8, job_row_elements.length 34 | end 35 | 36 | test "retry a selection of filtered jobs by class name" do 37 | assert_equal 9, job_row_elements.length 38 | 39 | fill_in "filter[job_class_name]", with: "FailingJob" 40 | assert_text /6 jobs found/i 41 | 42 | click_on "Retry selection" 43 | assert_text /retried 6 jobs/i 44 | assert_equal 3, job_row_elements.length 45 | end 46 | 47 | test "retry a selection of filtered jobs by queue name" do 48 | assert_equal 9, job_row_elements.length 49 | 50 | fill_in "filter[queue_name]", with: "queue_1" 51 | assert_text /4 jobs found/i 52 | 53 | click_on "Retry selection" 54 | assert_text /retried 4 jobs/i 55 | assert_equal 5, job_row_elements.length 56 | end 57 | 58 | test "retry a job from its details screen" do 59 | assert_equal 9, job_row_elements.length 60 | failed_job = ActiveJob.jobs.failed[2] 61 | visit job_path(failed_job.job_id) 62 | 63 | click_on "Retry" 64 | 65 | assert_text "Retried job with id #{failed_job.job_id}" 66 | assert_equal 8, job_row_elements.length 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/controllers/recurring_tasks_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::RecurringTasksControllerTest < ActionDispatch::IntegrationTest 4 | setup do 5 | # Work around a bug in Active Job's test helpers, whereby the test adapter is returned 6 | # when it's set, but the queue adapter name remains to be the previous adapter, bypassing 7 | # the set test adapter. This can be removed once the bug is fixed in Active Job 8 | PauseJob.queue_adapter = :solid_queue 9 | end 10 | 11 | teardown do 12 | PauseJob.queue_adapter = :resque 13 | end 14 | 15 | test "get recurring task list" do 16 | travel_to Time.parse("2024-10-30 19:07:10 UTC") do 17 | schedule_recurring_tasks_async(wait: 2.seconds) do 18 | get mission_control_jobs.application_recurring_tasks_url(@application) 19 | assert_response :ok 20 | 21 | assert_select "tr.recurring_task", 1 22 | assert_select "td a", "periodic_pause_job" 23 | assert_select "td", "PauseJob" 24 | assert_select "td", "every second" 25 | assert_select "td", /2024-10-30 19:07:1\d\.\d{3}/ 26 | end 27 | end 28 | end 29 | 30 | test "get recurring task details and job list" do 31 | travel_to Time.parse("2024-10-30 19:07:10 UTC") do 32 | schedule_recurring_tasks_async(wait: 1.seconds) do 33 | get mission_control_jobs.application_recurring_task_url(@application, "periodic_pause_job") 34 | assert_response :ok 35 | 36 | assert_select "h1", /periodic_pause_job/ 37 | assert_select "h2", "1 job" 38 | assert_select "tr.job", 1 39 | assert_select "td a", "PauseJob" 40 | assert_select "td", /2024-10-30 19:07:1\d\.\d{3}/ 41 | end 42 | end 43 | end 44 | 45 | test "redirect to recurring tasks list when recurring task doesn't exist" do 46 | schedule_recurring_tasks_async do 47 | get mission_control_jobs.application_recurring_task_url(@application, "invalid_key") 48 | assert_redirected_to mission_control_jobs.application_recurring_tasks_url(@application) 49 | 50 | follow_redirect! 51 | 52 | assert_select "article.is-danger", /Recurring task with id 'invalid_key' not found/ 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/adapter_testing/job_batches.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::AdapterTesting::JobBatches 2 | extend ActiveSupport::Concern 3 | extend ActiveSupport::Testing::Declarative 4 | 5 | included do 6 | # Ascending order 7 | assert_loop jobs_count: 10, order: :asc, batch_size: 5, expected_batches: [ 0..4, 5..9 ] 8 | assert_loop jobs_count: 12, order: :asc, batch_size: 5, expected_batches: [ 0..4, 5..9, 10..11 ] 9 | 10 | # Descending order 11 | assert_loop jobs_count: 10, order: :desc, batch_size: 5, expected_batches: [ 5..9, 0..4 ] 12 | assert_loop jobs_count: 12, order: :desc, batch_size: 5, expected_batches: [ 7..11, 2..6, 0..1 ] 13 | end 14 | 15 | class_methods do 16 | def assert_loop(jobs_count:, order:, batch_size:, expected_batches:) 17 | test "loop for #{jobs_count} jobs in #{order} order with #{batch_size} batch size: expecting #{expected_batches.inspect}" do 18 | jobs_count.times { |index| FailingJob.perform_later(index) } 19 | perform_enqueued_jobs 20 | 21 | batches = [] 22 | ActiveJob.jobs.failed.in_batches(of: batch_size, order: order) { |batch| batches << batch } 23 | 24 | assert_equal expected_batches.length, batches.length 25 | batches.each { |batch| assert_instance_of ActiveJob::JobsRelation, batch } 26 | 27 | expected_batches.each.with_index do |batch_range, index| 28 | assert_equal batch_range.to_a, batches[index].to_a.collect(&:serialized_arguments).flatten 29 | end 30 | end 31 | 32 | test "loop for #{jobs_count} jobs in #{order} order with #{batch_size} batch size and using a class name filter: expecting #{expected_batches.inspect}" do 33 | jobs_count.times { |index| FailingJob.perform_later(index) } 34 | perform_enqueued_jobs 35 | 36 | batches = [] 37 | ActiveJob.jobs.failed.where(job_class_name: "FailingJob").in_batches(of: batch_size, order: order) { |batch| batches << batch } 38 | 39 | assert_equal expected_batches.length, batches.length 40 | batches.each { |batch| assert_instance_of ActiveJob::JobsRelation, batch } 41 | 42 | expected_batches.each.with_index do |batch_range, index| 43 | assert_equal batch_range.to_a, batches[index].to_a.collect(&:serialized_arguments).flatten 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/system/discard_jobs_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class DiscardJobsTest < ApplicationSystemTestCase 4 | setup do 5 | 4.times { |index| FailingJob.set(queue: "queue_1").perform_later("failing-arg-#{index}") } 6 | 3.times { |index| FailingReloadedJob.set(queue: "queue_2").perform_later("failing-reloaded-arg-#{4 + index}") } 7 | 2.times { |index| FailingJob.set(queue: "queue_2").perform_later("failing-arg-#{7 + index}") } 8 | perform_enqueued_jobs 9 | 10 | visit jobs_path(:failed) 11 | end 12 | 13 | test "discard all failed jobs" do 14 | assert_equal 9, job_row_elements.length 15 | 16 | accept_confirm do 17 | click_on "Discard all" 18 | end 19 | 20 | assert_text "Discarded 9 jobs" 21 | assert_empty job_row_elements 22 | end 23 | 24 | test "discard a single job" do 25 | assert_equal 9, job_row_elements.length 26 | expected_job_id = ActiveJob.jobs.failed[2].job_id 27 | 28 | within_job_row "failing-arg-2" do 29 | accept_confirm do 30 | click_on "Discard" 31 | end 32 | end 33 | 34 | assert_text "Discarded job with id #{expected_job_id}" 35 | 36 | assert_equal 8, job_row_elements.length 37 | end 38 | 39 | test "discard a selection of filtered jobs by class name" do 40 | assert_equal 9, job_row_elements.length 41 | 42 | fill_in "filter[job_class_name]", with: "FailingReloadedJob" 43 | assert_text /3 jobs found/i 44 | 45 | accept_confirm do 46 | click_on "Discard selection" 47 | end 48 | 49 | assert_text /discarded 3 jobs/i 50 | assert_equal 6, job_row_elements.length 51 | end 52 | 53 | test "discard a selection of filtered jobs by queue name" do 54 | assert_equal 9, job_row_elements.length 55 | 56 | fill_in "filter[queue_name]", with: "queue_2" 57 | assert_text /5 jobs found/i 58 | 59 | accept_confirm do 60 | click_on "Discard selection" 61 | end 62 | 63 | assert_text /discarded 5 jobs/i 64 | assert_equal 4, job_row_elements.length 65 | end 66 | 67 | test "discard a job from its details screen" do 68 | assert_equal 9, job_row_elements.length 69 | failed_job = ActiveJob.jobs.failed[2] 70 | visit job_path(failed_job.job_id) 71 | 72 | accept_confirm do 73 | click_on "Discard" 74 | end 75 | 76 | assert_text "Discarded job with id #{failed_job.job_id}" 77 | assert_equal 8, job_row_elements.length 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /app/helpers/mission_control/jobs/jobs_helper.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::JobsHelper 2 | def job_title(job) 3 | job.job_class_name 4 | end 5 | 6 | def job_arguments(job) 7 | renderable_job_arguments_for(job).join(", ") 8 | end 9 | 10 | def failed_job_error(job) 11 | "#{job.last_execution_error.error_class}: #{job.last_execution_error.message}" 12 | end 13 | 14 | def clean_backtrace? 15 | params["clean_backtrace"] == "true" 16 | end 17 | 18 | def failed_job_backtrace(job, server) 19 | if clean_backtrace? && server&.backtrace_cleaner 20 | server.backtrace_cleaner.clean(job.last_execution_error.backtrace).join("\n") 21 | else 22 | job.last_execution_error.backtrace.join("\n") 23 | end 24 | end 25 | 26 | def attribute_names_for_job_status(status) 27 | case status.to_s 28 | when "failed" then [ "Error", "" ] 29 | when "blocked" then [ "Queue", "Blocked by", "Block expiry", "" ] 30 | when "finished" then [ "Queue", "Finished" ] 31 | when "scheduled" then [ "Queue", "Scheduled", "" ] 32 | when "in_progress" then [ "Queue", "Run by", "Running since" ] 33 | else [] 34 | end 35 | end 36 | 37 | def job_delayed?(job) 38 | job.scheduled_at.before?(MissionControl::Jobs.scheduled_job_delay_threshold.ago) 39 | end 40 | 41 | private 42 | 43 | def renderable_job_arguments_for(job) 44 | job.serialized_arguments.collect do |argument| 45 | as_renderable_argument(argument) 46 | end 47 | end 48 | 49 | def as_renderable_argument(argument) 50 | case argument 51 | when Hash 52 | as_renderable_hash(argument) 53 | when Array 54 | as_renderable_array(argument) 55 | else 56 | ActiveJob::Arguments.deserialize([ argument ]) 57 | end 58 | rescue ActiveJob::DeserializationError 59 | argument.to_s 60 | end 61 | 62 | def as_renderable_hash(argument) 63 | if argument["_aj_globalid"] 64 | # don't deserialize as the class might not exist in the host app running the engine 65 | argument["_aj_globalid"] 66 | elsif argument["_aj_serialized"] == "ActiveJob::Serializers::ModuleSerializer" 67 | argument["value"] 68 | else 69 | ActiveJob::Arguments.deserialize([ argument ]) 70 | end 71 | end 72 | 73 | def as_renderable_array(argument) 74 | "(#{argument.collect { |part| as_renderable_argument(part) }.join(", ")})" 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/adapter_testing/queues.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::AdapterTesting::Queues 2 | extend ActiveSupport::Concern 3 | extend ActiveSupport::Testing::Declarative 4 | 5 | test "fetch the list of queues" do 6 | create_queues "queue_1", "queue_2" 7 | 8 | queues = ActiveJob.queues 9 | 10 | assert_equal 2, queues.length 11 | assert_equal "queue_1", queues["queue_1"].name 12 | assert_equal "queue_2", queues["queue_2"].name 13 | end 14 | 15 | test "lookup queue by name" do 16 | create_queues "queue_1", "queue_2" 17 | 18 | assert_equal "queue_1", ActiveJob.queues[:queue_1].name 19 | assert_equal "queue_1", ActiveJob.queues["queue_1"].name 20 | assert_nil ActiveJob.queues[:queue_3] 21 | end 22 | 23 | test "pause and resume queues" do 24 | create_queues "queue_1", "queue_2" 25 | 26 | queue = ActiveJob.queues[:queue_1] 27 | 28 | assert queue.active? 29 | assert_not queue.paused? 30 | 31 | queue.pause 32 | assert_not queue.reload.active? 33 | assert queue.paused? 34 | 35 | queue.resume 36 | assert queue.reload.active? 37 | assert_not queue.paused? 38 | end 39 | 40 | test "queue size" do 41 | 3.times { DynamicQueueJob("queue_1").perform_later } 42 | 43 | queue = ActiveJob.queues[:queue_1] 44 | assert_equal 3, queue.size 45 | assert_equal 3, queue.length 46 | end 47 | 48 | test "queue sizes for multiple queues" do 49 | 3.times { DynamicQueueJob("queue_1").perform_later } 50 | 5.times { DynamicQueueJob("queue_2").perform_later } 51 | 52 | assert_equal 3, ActiveJob.queues[:queue_1].size 53 | assert_equal 5, ActiveJob.queues[:queue_2].size 54 | end 55 | 56 | test "clear a queue" do 57 | DynamicQueueJob("queue_1").perform_later 58 | queue = ActiveJob.queues[:queue_1] 59 | assert queue 60 | 61 | queue.clear 62 | queue = ActiveJob.queues[:queue_1] 63 | assert_not queue 64 | end 65 | 66 | test "check if a queue is empty" do 67 | 3.times { DynamicQueueJob("queue_1").perform_later } 68 | queue = ActiveJob.queues[:queue_1] 69 | 70 | assert_not queue.empty? 71 | end 72 | 73 | test "fetch the pending jobs in a queue" do 74 | DummyJob.queue_as :queue_1 75 | 3.times { DummyJob.perform_later } 76 | DummyJob.queue_as :queue_2 77 | 5.times { DummyJob.perform_later } 78 | 79 | assert_equal 3, ActiveJob.queues[:queue_1].jobs.pending.to_a.length 80 | assert_equal 5, ActiveJob.queues[:queue_2].jobs.pending.to_a.length 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = :none 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | config.active_job.queue_adapter = :resque 61 | 62 | # Silence Solid Queue logging 63 | config.solid_queue.logger = ActiveSupport::Logger.new(nil) 64 | end 65 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Suppress logger output for asset requests. 60 | config.assets.quiet = true 61 | 62 | # Raises error for missing translations. 63 | # config.i18n.raise_on_missing_translations = true 64 | 65 | # Annotate rendered view with file names. 66 | # config.action_view.annotate_rendered_view_with_filenames = true 67 | 68 | # Uncomment if you wish to allow Action Cable access from any origin. 69 | # config.action_cable.disable_request_forgery_protection = true 70 | 71 | config.active_job.queue_adapter = :resque 72 | 73 | # Silence Solid Queue logging 74 | config.solid_queue.logger = ActiveSupport::Logger.new(nil) 75 | end 76 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/resque_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveJob::QueueAdapters::ResqueAdapterTest < ActiveSupport::TestCase 4 | include ResqueHelper 5 | 6 | test "create a new adapter with the default resque redis instance" do 7 | assert_no_changes -> { Resque.redis } do 8 | ActiveJob::QueueAdapters::ResqueAdapter.new 9 | end 10 | end 11 | 12 | test "execute a block of code activating a different redis instance" do 13 | old_redis = create_resque_redis "old_redis" 14 | new_redis = create_resque_redis "new_redis" 15 | 16 | adapter = ActiveJob::QueueAdapters::ResqueAdapter.new(new_redis) 17 | Resque.redis = old_redis 18 | 19 | assert_equal old_redis, current_resque_redis 20 | 21 | adapter.activating do 22 | @invoked = true 23 | assert_equal new_redis, current_resque_redis 24 | end 25 | 26 | assert @invoked 27 | assert_equal old_redis, current_resque_redis 28 | end 29 | 30 | test "activating different redis connections is thread-safe" do 31 | redis_1 = create_resque_redis("redis_1") 32 | adapter_1 = ActiveJob::QueueAdapters::ResqueAdapter.new(redis_1) 33 | redis_2 = create_resque_redis("redis_2") 34 | adapter_2 = ActiveJob::QueueAdapters::ResqueAdapter.new(redis_2) 35 | 36 | { redis_1 => adapter_1, redis_2 => adapter_2 }.flat_map do |redis, adapter| 37 | 20.times.collect do 38 | Thread.new do 39 | adapter.activating do 40 | sleep_to_force_race_condition 41 | assert_equal redis, current_resque_redis 42 | end 43 | end 44 | end 45 | end.each(&:join) 46 | end 47 | 48 | test "use different resque adapters via active job" do 49 | redis_1 = create_resque_redis("redis_1") 50 | adapter_1 = ActiveJob::QueueAdapters::ResqueAdapter.new(redis_1) 51 | redis_2 = create_resque_redis("redis_2") 52 | adapter_2 = ActiveJob::QueueAdapters::ResqueAdapter.new(redis_2) 53 | 54 | with_active_job_adapter(adapter_1) do 55 | adapter_1.activating do 56 | 5.times { DummyJob.perform_later } 57 | end 58 | end 59 | 60 | with_active_job_adapter(adapter_2) do 61 | adapter_2.activating do 62 | 10.times { DummyJob.perform_later } 63 | end 64 | end 65 | 66 | with_active_job_adapter(adapter_1) do 67 | adapter_1.activating do 68 | assert_equal 5, ActiveJob.jobs.pending.count 69 | end 70 | end 71 | 72 | with_active_job_adapter(adapter_2) do 73 | adapter_2.activating do 74 | assert_equal 10, ActiveJob.jobs.pending.count 75 | end 76 | end 77 | end 78 | 79 | private 80 | def with_active_job_adapter(adapter, &block) 81 | previous_adapter = ActiveJob::Base.current_queue_adapter 82 | ActiveJob::Base.current_queue_adapter = adapter 83 | yield 84 | ensure 85 | ActiveJob::Base.current_queue_adapter = previous_adapter 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/system/show_failed_job_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../application_system_test_case" 2 | 3 | class ShowFailedJobsTest < ApplicationSystemTestCase 4 | setup do 5 | 10.times { |index| FailingJob.perform_later(index) } 6 | perform_enqueued_jobs 7 | visit jobs_path(:failed) 8 | end 9 | 10 | test "click on a failed job to see its details" do 11 | within_job_row /FailingJob\s*2/ do 12 | click_on "FailingJob" 13 | end 14 | 15 | assert_text /arguments\s*2/i 16 | assert_text /failing_job.rb/ 17 | end 18 | 19 | test "click on a failed job error to see its error information" do 20 | within_job_row /FailingJob\s*2/ do 21 | click_on "RuntimeError: This always fails!" 22 | end 23 | 24 | assert_text /failing_job.rb/ 25 | end 26 | 27 | test "show empty notice when no jobs" do 28 | ActiveJob.jobs.failed.discard_all 29 | visit jobs_path(:failed) 30 | assert_text /there are no failed jobs/i 31 | end 32 | 33 | test "Has Clean/Full buttons when a backtrace cleaner is configured" do 34 | visit jobs_path(:failed) 35 | within_job_row(/FailingJob\s*2/) do 36 | click_on "RuntimeError: This always fails!" 37 | end 38 | 39 | assert_selector ".backtrace-toggle-selector" 40 | end 41 | 42 | test "Does not offer Clean/Full buttons when a backtrace cleaner is not configured" do 43 | setup do 44 | # grab the current state 45 | @backtrace_cleaner = MissionControl::Jobs.backtrace_cleaner 46 | @applications = MissionControl::Jobs.backtrace_cleaner 47 | 48 | # reset the state 49 | MissionControl::Jobs.backtrace_cleaner = nil 50 | MissionControl::Jobs.applications = Applications.new 51 | 52 | # Setup the application with what we had before *minus* a backtrace cleaner 53 | @applications.each do |application| 54 | MissionControl::Jobs.applications.add(application.name).tap do |it| 55 | application.servers.each do |server| 56 | it.add_servers(server.name, server.queue_adapter) 57 | end 58 | end 59 | end 60 | end 61 | 62 | teardown do 63 | # reset back to the known state before the start of the test 64 | MissionControl::Jobs.backtrace_cleaner = @backtrace_cleaner 65 | MissionControl::Jobs.applications = @application 66 | end 67 | 68 | visit jobs_path(:failed) 69 | within_job_row(/FailingJob\s*2/) do 70 | click_on "RuntimeError: This always fails!" 71 | end 72 | 73 | assert_no_selector ".backtrace-toggle-selector" 74 | end 75 | 76 | test "click on 'clean' shows a backtrace cleaned by the Rails default backtrace cleaner" do 77 | visit jobs_path(:failed) 78 | within_job_row /FailingJob\s*2/ do 79 | click_on "RuntimeError: This always fails!" 80 | end 81 | 82 | assert_selector ".backtrace-toggle-selector" 83 | 84 | within ".backtrace-toggle-selector" do 85 | click_on "Clean" 86 | end 87 | 88 | assert_selector "pre.backtrace-content", text: /.*/, visible: true 89 | 90 | within ".backtrace-toggle-selector" do 91 | click_on "Full" 92 | end 93 | 94 | assert_selector "pre.backtrace-content", text: /.*/, visible: true 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/controllers/jobs_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MissionControl::Jobs::JobsControllerTest < ActionDispatch::IntegrationTest 4 | setup do 5 | DummyJob.queue_as :queue_1 6 | end 7 | 8 | test "get job details" do 9 | job = DummyJob.perform_later(42) 10 | 11 | get mission_control_jobs.application_job_url(@application, job.job_id) 12 | assert_response :ok 13 | 14 | assert_select "h1", /DummyJob\s+pending/ 15 | assert_includes response.body, job.job_id 16 | assert_select "div.tag a", "queue_1" 17 | 18 | get mission_control_jobs.application_job_url(@application, job.job_id, filter: { queue_name: "queue_1" }) 19 | assert_response :ok 20 | 21 | assert_select "h1", /DummyJob\s+pending/ 22 | assert_includes response.body, job.job_id 23 | assert_select "div.tag a", "queue_1" 24 | end 25 | 26 | test "get jobs and job details when there are multiple instances of the same job due to automatic retries" do 27 | time = Time.now 28 | job = AutoRetryingJob.perform_later 29 | 30 | perform_enqueued_jobs_async 31 | 32 | get mission_control_jobs.application_jobs_url(@application, :finished) 33 | assert_response :ok 34 | 35 | assert_select "tr.job", 2 36 | assert_select "tr.job", /AutoRetryingJob\s+Enqueued #{time_pattern(time)}\s+default/ 37 | 38 | get mission_control_jobs.application_job_url(@application, job.job_id) 39 | assert_response :ok 40 | 41 | assert_select "h1", /AutoRetryingJob\s+failed\s+/ 42 | assert_includes response.body, job.job_id 43 | assert_select "div.is-danger", "failed" 44 | end 45 | 46 | test "redirect to queue when job doesn't exist" do 47 | job = DummyJob.perform_later(42) 48 | 49 | get mission_control_jobs.application_job_url(@application, job.job_id + "0", filter: { queue_name: "queue_1" }) 50 | assert_redirected_to mission_control_jobs.application_queue_path(@application, :queue_1) 51 | end 52 | 53 | test "get scheduled jobs" do 54 | time = Time.now 55 | DummyJob.set(wait: 3.minutes).perform_later 56 | DummyJob.set(wait: 1.minute).perform_later 57 | 58 | get mission_control_jobs.application_jobs_url(@application, :scheduled) 59 | assert_response :ok 60 | 61 | assert_select "tr.job", 2 62 | assert_select "tr.job", /DummyJob\s+Enqueued #{time_pattern(time)}\s+queue_1\s+#{time_pattern(time + 3.minute)}/ 63 | assert_select "tr.job", /DummyJob\s+Enqueued #{time_pattern(time)}\s+queue_1\s+#{time_pattern(time + 1.minute)}/ 64 | assert_select "tr.job", /Discard/ 65 | end 66 | 67 | test "get scheduled jobs when some are delayed" do 68 | DummyJob.set(wait: 5.minutes).perform_later(37) 69 | DummyJob.set(wait: 7.minutes).perform_later(42) 70 | 71 | get mission_control_jobs.application_jobs_url(@application, :scheduled) 72 | assert_response :ok 73 | assert_select "tr.job", 2 do # lists two jobs 74 | assert_select "div.tag", text: /delayed/, count: 0 # no delayed tag 75 | end 76 | 77 | travel 5.minutes + MissionControl::Jobs.scheduled_job_delay_threshold + 1.second 78 | 79 | get mission_control_jobs.application_jobs_url(@application, :scheduled) 80 | assert_response :ok 81 | assert_select "tr.job", 2 do # lists two jobs 82 | assert_select "div.tag", text: /delayed/, count: 1 # total of one delayed tag 83 | end 84 | end 85 | 86 | private 87 | def time_pattern(time) 88 | /#{time.utc.strftime("%Y-%m-%d %H:%M")}:\d{2}\.\d{3}/ 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/adapter_testing/discard_jobs.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::AdapterTesting::DiscardJobs 2 | extend ActiveSupport::Concern 3 | extend ActiveSupport::Testing::Declarative 4 | 5 | test "discard all failed jobs" do 6 | 10.times { |index| FailingJob.perform_later(index) } 7 | perform_enqueued_jobs 8 | 9 | failed_jobs = ActiveJob.jobs.failed 10 | assert_not_empty failed_jobs 11 | failed_jobs.discard_all 12 | 13 | assert_empty failed_jobs.reload 14 | 15 | perform_enqueued_jobs 16 | assert_equal 10, FailingJob.invocations.count # not retried 17 | end 18 | 19 | test "discard all pending jobs" do 20 | 10.times { |index| DummyJob.perform_later(index) } 21 | 22 | pending_jobs = ActiveJob.queues[:default].jobs.pending 23 | assert_not_empty pending_jobs 24 | pending_jobs.discard_all 25 | 26 | assert_empty pending_jobs.reload 27 | 28 | perform_enqueued_jobs 29 | end 30 | 31 | test "discard all failed withing a given page" do 32 | 10.times { |index| FailingJob.perform_later(index) } 33 | perform_enqueued_jobs 34 | 35 | failed_jobs = ActiveJob.jobs.failed.offset(2).limit(3) 36 | failed_jobs.discard_all 37 | 38 | assert_equal 7, ActiveJob.jobs.failed.count 39 | 40 | [ 0, 1, 5, 6, 7, 8, 9 ].each.with_index do |expected_argument, index| 41 | assert_equal [ expected_argument ], ActiveJob.jobs.failed[index].serialized_arguments 42 | end 43 | end 44 | 45 | test "discard only failed of a given class" do 46 | 5.times { FailingJob.perform_later } 47 | 10.times { FailingReloadedJob.perform_later } 48 | perform_enqueued_jobs 49 | 50 | ActiveJob.jobs.failed.where(job_class_name: "FailingJob").discard_all 51 | 52 | assert_empty ActiveJob.jobs.failed.where(job_class_name: "FailingJob") 53 | assert_equal 10, ActiveJob.jobs.failed.where(job_class_name: "FailingReloadedJob").count 54 | end 55 | 56 | test "discard only failed of a given queue" do 57 | FailingJob.queue_as :queue_1 58 | FailingReloadedJob.queue_as :queue_2 59 | 60 | 5.times { FailingJob.perform_later } 61 | 10.times { FailingReloadedJob.perform_later } 62 | perform_enqueued_jobs 63 | ActiveJob.jobs.failed.where(queue_name: :queue_1).discard_all 64 | 65 | assert_empty ActiveJob.jobs.failed.where(job_class_name: "FailingJob") 66 | assert_equal 10, ActiveJob.jobs.failed.where(job_class_name: "FailingReloadedJob").count 67 | end 68 | 69 | test "discard all pending withing a given page" do 70 | 10.times { |index| DummyJob.perform_later(index) } 71 | 72 | pending_jobs = ActiveJob.queues[:default].jobs.pending 73 | page_of_jobs = pending_jobs.offset(2).limit(3) 74 | page_of_jobs.discard_all 75 | 76 | assert_equal 7, pending_jobs.count 77 | 78 | [ 0, 1, 5, 6, 7, 8, 9 ].each.with_index do |expected_argument, index| 79 | assert_equal [ expected_argument ], pending_jobs[index].serialized_arguments 80 | end 81 | end 82 | 83 | test "discard a single failed job" do 84 | FailingJob.perform_later 85 | perform_enqueued_jobs 86 | 87 | assert_not_empty ActiveJob.jobs.failed 88 | 89 | failed_job = ActiveJob.jobs.failed.last 90 | failed_job.discard 91 | 92 | assert_empty ActiveJob.jobs.failed 93 | 94 | perform_enqueued_jobs 95 | assert_equal 1, FailingJob.invocations.count # not retried 96 | end 97 | 98 | test "discard a single pending job" do 99 | DummyJob.perform_later 100 | 101 | pending_jobs = ActiveJob.queues[:default].jobs.pending 102 | assert_not_empty pending_jobs 103 | 104 | pending_job = pending_jobs.last 105 | pending_job.discard 106 | 107 | assert_empty pending_jobs.reload 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /test/dummy/db/seeds.rb: -------------------------------------------------------------------------------- 1 | def clean_redis 2 | all_keys = Resque.redis.keys("*") 3 | Resque.redis.del all_keys if all_keys.any? 4 | end 5 | 6 | def clean_database 7 | SolidQueue::Job.all.each(&:destroy) 8 | SolidQueue::Process.all.each(&:destroy) 9 | end 10 | 11 | class JobsLoader 12 | attr_reader :application, :server, :failed_jobs_count, :regular_jobs_count, :finished_jobs_count, :blocked_jobs_count 13 | 14 | def initialize(application, server, failed_jobs_count: 100, regular_jobs_count: 50) 15 | @application = application 16 | @server = server 17 | @failed_jobs_count = randomize(failed_jobs_count) 18 | @regular_jobs_count = randomize(regular_jobs_count) 19 | @finished_jobs_count = randomize(regular_jobs_count) 20 | @blocked_jobs_count = randomize(regular_jobs_count) 21 | end 22 | 23 | def load 24 | server.activating do 25 | load_finished_jobs 26 | load_failed_jobs 27 | load_regular_jobs 28 | load_blocked_jobs if server.queue_adapter.supported_job_statuses.include?(:blocked) 29 | end 30 | end 31 | 32 | private 33 | def load_failed_jobs 34 | puts "Generating #{failed_jobs_count} failed jobs for #{application} - #{server}..." 35 | failed_jobs_count.times { |index| enqueue_one_of FailingJob => index, FailingReloadedJob => index, FailingPostJob => [ Post.last, 1.year.ago ] } 36 | perform_jobs 37 | end 38 | 39 | def perform_jobs 40 | case server.queue_adapter_name 41 | when :resque 42 | worker = Resque::Worker.new("*") 43 | worker.work(0.0) 44 | when :solid_queue 45 | worker = SolidQueue::Worker.new(queues: "*", threads: 1, polling_interval: 0.01) 46 | worker.mode = :inline 47 | worker.start 48 | else 49 | raise "Don't know how to dispatch jobs for #{server.queue_adapter_name} adapter" 50 | end 51 | end 52 | 53 | def load_finished_jobs 54 | puts "Generating #{finished_jobs_count} finished jobs for #{application} - #{server}..." 55 | regular_jobs_count.times do |index| 56 | enqueue_one_of DummyJob => index, DummyReloadedJob => index 57 | end 58 | perform_jobs 59 | end 60 | 61 | def load_regular_jobs 62 | puts "Generating #{regular_jobs_count} regular jobs for #{application} - #{server}..." 63 | regular_jobs_count.times do |index| 64 | enqueue_one_of DummyJob => index, DummyReloadedJob => index 65 | end 66 | end 67 | 68 | def load_blocked_jobs 69 | puts "Generating #{blocked_jobs_count} blocked jobs for #{application} - #{server}..." 70 | blocked_jobs_count.times do |index| 71 | enqueue_one_of BlockingJob => index 72 | end 73 | end 74 | 75 | def with_random_queue(job_class) 76 | random_queue = [ "background", "reports", "default", "realtime" ].sample 77 | job_class.tap do 78 | job_class.queue_as random_queue 79 | end 80 | end 81 | 82 | def enqueue_one_of(arguments_by_job_class) 83 | job_class = arguments_by_job_class.keys.sample 84 | arguments = arguments_by_job_class[job_class] 85 | with_random_queue(job_class).perform_later(*Array(arguments)) 86 | end 87 | 88 | def randomize(value) 89 | (value * (1 + rand)).to_i 90 | end 91 | end 92 | 93 | puts "Deleting existing jobs..." 94 | clean_redis 95 | clean_database 96 | 97 | BASE_COUNT = (ENV["COUNT"].presence || 100).to_i 98 | 99 | Post.find_or_create_by!(title: "Hello World!", body: "This is my first post.") 100 | 101 | MissionControl::Jobs.applications.each do |application| 102 | application.servers.each do |server| 103 | JobsLoader.new(application, server, failed_jobs_count: BASE_COUNT, regular_jobs_count: BASE_COUNT / 2).load 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/active_job/queue_adapters/adapter_testing/retry_jobs.rb: -------------------------------------------------------------------------------- 1 | module ActiveJob::QueueAdapters::AdapterTesting::RetryJobs 2 | extend ActiveSupport::Concern 3 | extend ActiveSupport::Testing::Declarative 4 | 5 | test "retrying jobs raises an error for jobs that are not in a failed state" do 6 | 10.times { DummyJob.perform_later } 7 | assert_raises ActiveJob::Errors::InvalidOperation do 8 | ActiveJob.jobs.retry_all 9 | end 10 | end 11 | 12 | test "retry all failed jobs" do 13 | 10.times { |index| FailingJob.perform_later(index) } 14 | perform_enqueued_jobs 15 | 16 | failed_jobs = ActiveJob.jobs.failed 17 | assert_not_empty failed_jobs 18 | failed_jobs.retry_all 19 | 20 | assert_empty failed_jobs.reload 21 | 22 | perform_enqueued_jobs 23 | assert_equal 2 * 10, FailingJob.invocations.count 24 | end 25 | 26 | test "retry all failed jobs when pagination kicks in" do 27 | 10.times { |index| WithPaginationFailingJob.perform_later(index) } 28 | perform_enqueued_jobs 29 | 30 | failed_jobs = ActiveJob.jobs.failed 31 | assert_not_empty failed_jobs 32 | failed_jobs.retry_all 33 | 34 | assert_empty failed_jobs.reload 35 | end 36 | 37 | test "retry all failed withing a given page" do 38 | 10.times { |index| FailingJob.perform_later(index) } 39 | perform_enqueued_jobs 40 | 41 | assert_equal 10, ActiveJob.jobs.failed.count 42 | 43 | failed_jobs = ActiveJob.jobs.failed.offset(2).limit(3) 44 | failed_jobs.retry_all 45 | 46 | assert_equal 7, ActiveJob.jobs.failed.count 47 | 48 | [ 0, 1, 5, 6, 7, 8, 9 ].each.with_index do |expected_argument, index| 49 | assert_equal [ expected_argument ], ActiveJob.jobs.failed[index].serialized_arguments 50 | end 51 | end 52 | 53 | test "retry all failed of a given kind" do 54 | 10.times { |index| FailingJob.perform_later(index) } 55 | 5.times { |index| FailingReloadedJob.perform_later(index) } 56 | perform_enqueued_jobs 57 | 58 | assert_equal 15, ActiveJob.jobs.failed.count 59 | 60 | failed_jobs = ActiveJob.jobs.failed.where(job_class_name: "FailingReloadedJob") 61 | failed_jobs.retry_all 62 | 63 | assert_equal 10, ActiveJob.jobs.failed.count 64 | 65 | assert_not ActiveJob.jobs.failed.any? { |job| job.is_a?(FailingReloadedJob) } 66 | 67 | perform_enqueued_jobs 68 | assert_equal 1 * 10, FailingJob.invocations.count 69 | assert_equal 2 * 5, FailingReloadedJob.invocations.count 70 | end 71 | 72 | test "retry all failed of a given queue" do 73 | FailingJob.queue_as :queue_1 74 | FailingReloadedJob.queue_as :queue_2 75 | 76 | 10.times { |index| FailingJob.perform_later(index) } 77 | 5.times { |index| FailingReloadedJob.perform_later(index) } 78 | perform_enqueued_jobs 79 | 80 | assert_equal 15, ActiveJob.jobs.failed.count 81 | 82 | failed_jobs = ActiveJob.jobs.failed.where(queue_name: :queue_2) 83 | failed_jobs.retry_all 84 | 85 | assert_equal 10, ActiveJob.jobs.failed.count 86 | 87 | assert_not ActiveJob.jobs.failed.any? { |job| job.is_a?(FailingReloadedJob) } 88 | 89 | perform_enqueued_jobs 90 | assert_equal 1 * 10, FailingJob.invocations.count 91 | assert_equal 2 * 5, FailingReloadedJob.invocations.count 92 | end 93 | 94 | test "retry a single failed job" do 95 | FailingJob.perform_later 96 | perform_enqueued_jobs 97 | 98 | assert_not_empty ActiveJob.jobs.failed 99 | 100 | failed_job = ActiveJob.jobs.failed.last 101 | failed_job.retry 102 | 103 | assert_empty ActiveJob.jobs.failed 104 | 105 | perform_enqueued_jobs 106 | assert_equal 2, FailingJob.invocations.count 107 | end 108 | 109 | test "retrying a single job fails if the job does not exist" do 110 | FailingJob.perform_later 111 | perform_enqueued_jobs 112 | failed_job = ActiveJob.jobs.failed.last 113 | delete_all_jobs 114 | 115 | assert_raise ActiveJob::Errors::JobNotFoundError do 116 | failed_job.retry 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require_relative "../test/dummy/config/environment" 5 | 6 | ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] 7 | ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) 8 | require "rails/test_help" 9 | require "mocha/minitest" 10 | 11 | require "debug" 12 | 13 | # Load fixtures from the engine 14 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 15 | ActiveSupport::TestCase.fixture_paths = [ File.expand_path("fixtures", __dir__) ] 16 | ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths 17 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_paths.first + "/files" 18 | ActiveSupport::TestCase.fixtures :all 19 | end 20 | 21 | require_relative "active_job/queue_adapters/adapter_testing" 22 | Dir[File.join(__dir__, "support", "*.rb")].each { |file| require file } 23 | Dir[File.join(__dir__, "active_job", "queue_adapters", "adapter_testing", "*.rb")].each { |file| require file } 24 | 25 | ENV["FORK_PER_JOB"] = "false" # Disable forking when dispatching resque jobs 26 | 27 | class ActiveSupport::TestCase 28 | include JobsHelper, JobQueuesHelper, ThreadHelper 29 | 30 | setup do 31 | @original_applications = MissionControl::Jobs.applications 32 | reset_executions_for_job_test_classes 33 | delete_adapters_data 34 | ActiveJob::Base.current_queue_adapter = nil 35 | reset_configured_queues_for_job_classes 36 | end 37 | 38 | teardown do 39 | MissionControl::Jobs.applications = @original_applications 40 | end 41 | 42 | private 43 | def reset_executions_for_job_test_classes 44 | ApplicationJob.descendants.including(ApplicationJob).each { |klass| klass.invocations&.clear } 45 | end 46 | 47 | def delete_adapters_data 48 | delete_resque_data 49 | delete_solid_queue_data 50 | end 51 | 52 | alias delete_all_jobs delete_adapters_data 53 | 54 | def delete_resque_data 55 | redis = root_resque_redis 56 | all_keys = redis.keys("test*") 57 | redis.del all_keys if all_keys.any? 58 | end 59 | 60 | def delete_solid_queue_data 61 | SolidQueue::Job.find_each(&:destroy) 62 | SolidQueue::Process.find_each(&:destroy) 63 | end 64 | 65 | def root_resque_redis 66 | @root_resque_redis ||= Redis.new(host: "localhost", port: 6379) 67 | end 68 | 69 | def reset_configured_queues_for_job_classes 70 | ApplicationJob.descendants.including(ApplicationJob).each { |klass| klass.queue_as :default } 71 | end 72 | end 73 | 74 | class ActionDispatch::IntegrationTest 75 | # Integration tests just use Solid Queue for now 76 | setup do 77 | MissionControl::Jobs.applications.add("integration-tests", { solid_queue: queue_adapter_for_test }) 78 | 79 | @application = MissionControl::Jobs.applications["integration-tests"] 80 | @server = @application.servers[:solid_queue] 81 | @worker = SolidQueue::Worker.new(queues: "*", threads: 2, polling_interval: 0.01) 82 | 83 | recurring_task = { periodic_pause_job: { class: "PauseJob", schedule: "every second" } } 84 | @scheduler = SolidQueue::Scheduler.new(recurring_tasks: recurring_task) 85 | end 86 | 87 | teardown do 88 | @worker.stop 89 | @scheduler.stop 90 | end 91 | 92 | private 93 | def queue_adapter_for_test 94 | ActiveJob::QueueAdapters::SolidQueueAdapter.new 95 | end 96 | 97 | def register_workers(count: 1) 98 | count.times { |i| SolidQueue::Process.register(kind: "Worker", pid: i, name: "worker-#{i}") } 99 | end 100 | 101 | def perform_enqueued_jobs_async(wait: 1.second) 102 | @worker.start 103 | sleep(wait) 104 | 105 | yield if block_given? 106 | @worker.stop 107 | end 108 | 109 | def schedule_recurring_tasks_async(wait: 1.second) 110 | @scheduler.start 111 | sleep(wait) 112 | 113 | yield if block_given? 114 | @scheduler.stop 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = "wss://example.com/cable" 46 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [ :request_id ] 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :mem_cache_store 60 | 61 | # Use a real queuing backend for Active Job (and separate queues per environment). 62 | # config.active_job.queue_adapter = :resque 63 | # config.active_job.queue_name_prefix = "dummy_production" 64 | 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Don't log any deprecations. 76 | config.active_support.report_deprecations = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Use a different logger for distributed setups. 82 | # require "syslog/logger" 83 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 84 | 85 | if ENV["RAILS_LOG_TO_STDOUT"].present? 86 | logger = ActiveSupport::Logger.new(STDOUT) 87 | logger.formatter = config.log_formatter 88 | config.logger = ActiveSupport::TaggedLogging.new(logger) 89 | end 90 | 91 | # Do not dump schema after migrations. 92 | config.active_record.dump_schema_after_migration = false 93 | end 94 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/engine.rb: -------------------------------------------------------------------------------- 1 | require "mission_control/jobs/version" 2 | require "mission_control/jobs/engine" 3 | 4 | require "importmap-rails" 5 | require "turbo-rails" 6 | require "stimulus-rails" 7 | 8 | module MissionControl 9 | module Jobs 10 | class Engine < ::Rails::Engine 11 | isolate_namespace MissionControl::Jobs 12 | 13 | config.mission_control = ActiveSupport::OrderedOptions.new unless config.try(:mission_control) 14 | config.mission_control.jobs = ActiveSupport::OrderedOptions.new 15 | 16 | config.before_initialize do 17 | config.mission_control.jobs.applications = MissionControl::Jobs::Applications.new 18 | config.mission_control.jobs.backtrace_cleaner ||= Rails::BacktraceCleaner.new 19 | 20 | config.mission_control.jobs.each do |key, value| 21 | MissionControl::Jobs.public_send("#{key}=", value) 22 | end 23 | 24 | if MissionControl::Jobs.adapters.empty? 25 | MissionControl::Jobs.adapters << (config.active_job.queue_adapter || :async) 26 | end 27 | end 28 | 29 | initializer "mission_control-jobs.active_job.extensions" do 30 | ActiveSupport.on_load :active_job do 31 | include ActiveJob::Querying 32 | include ActiveJob::Executing 33 | include ActiveJob::Failed 34 | ActiveJob.extend ActiveJob::Querying::Root 35 | end 36 | end 37 | 38 | config.before_initialize do 39 | if MissionControl::Jobs.adapters.include?(:resque) 40 | require "resque/thread_safe_redis" 41 | ActiveJob::QueueAdapters::ResqueAdapter.prepend ActiveJob::QueueAdapters::ResqueExt 42 | Resque.prepend Resque::ThreadSafeRedis 43 | end 44 | 45 | if MissionControl::Jobs.adapters.include?(:solid_queue) 46 | ActiveJob::QueueAdapters::SolidQueueAdapter.prepend ActiveJob::QueueAdapters::SolidQueueExt 47 | end 48 | 49 | ActiveJob::QueueAdapters::AsyncAdapter.include MissionControl::Jobs::Adapter 50 | end 51 | 52 | config.after_initialize do |app| 53 | unless app.config.eager_load 54 | # When loading classes lazily (development), we want to make sure 55 | # the base host +ApplicationController+ class is loaded when loading the 56 | # Engine's +ApplicationController+, or it will fail to load the class. 57 | MissionControl::Jobs.base_controller_class.constantize 58 | end 59 | 60 | if MissionControl::Jobs.applications.empty? 61 | queue_adapters_by_name = MissionControl::Jobs.adapters.each_with_object({}) do |adapter, hsh| 62 | hsh[adapter] = ActiveJob::QueueAdapters.lookup(adapter).new 63 | end 64 | 65 | MissionControl::Jobs.applications.add(app.class.module_parent.name, queue_adapters_by_name) 66 | end 67 | end 68 | 69 | console do 70 | require "irb" 71 | 72 | IRB::Command.register :connect_to, Console::ConnectTo 73 | IRB::Command.register :jobs_help, Console::JobsHelp 74 | 75 | IRB::Context.prepend(MissionControl::Jobs::Console::Context) 76 | 77 | MissionControl::Jobs.delay_between_bulk_operation_batches = 2 78 | MissionControl::Jobs.logger = ActiveSupport::Logger.new(STDOUT) 79 | 80 | if MissionControl::Jobs.show_console_help 81 | puts "\n\nType 'jobs_help' to see how to connect to the available job servers to manage jobs\n\n" 82 | end 83 | end 84 | 85 | initializer "mission_control-jobs.assets" do |app| 86 | app.config.assets.paths << root.join("app/javascript") 87 | app.config.assets.precompile += %w[ mission_control_jobs_manifest ] 88 | end 89 | 90 | initializer "mission_control-jobs.importmap", after: "importmap" do |app| 91 | MissionControl::Jobs.importmap.draw(root.join("config/importmap.rb")) 92 | MissionControl::Jobs.importmap.cache_sweeper(watches: root.join("app/javascript")) 93 | 94 | ActiveSupport.on_load(:action_controller_base) do 95 | before_action { MissionControl::Jobs.importmap.cache_sweeper.execute_if_updated } 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/mission_control/jobs/adapter.rb: -------------------------------------------------------------------------------- 1 | module MissionControl::Jobs::Adapter 2 | def activating(&block) 3 | block.call 4 | end 5 | 6 | def supports_job_status?(status) 7 | supported_job_statuses.include?(status) 8 | end 9 | 10 | def supported_job_statuses 11 | # All adapters need to support these at a minimum 12 | [ :pending, :failed ] 13 | end 14 | 15 | def supports_job_filter?(jobs_relation, filter) 16 | supported_job_filters(jobs_relation).include?(filter) 17 | end 18 | 19 | # List of filters supported natively. Non-supported filters are done in memory. 20 | def supported_job_filters(jobs_relation) 21 | [] 22 | end 23 | 24 | def supports_queue_pausing? 25 | true 26 | end 27 | 28 | def exposes_workers? 29 | false 30 | end 31 | 32 | def supports_recurring_tasks? 33 | false 34 | end 35 | 36 | # Returns an array with the list of recurring tasks. Each task is represented as a hash 37 | # with these attributes: 38 | # { 39 | # id: "periodic-job", 40 | # job_class_name: "MyJob", 41 | # arguments: [ 123, { arg: :value }] 42 | # schedule: "every monday at 9 am", 43 | # last_enqueued_at: Fri, 26 Jan 2024 20:31:09.652174000 UTC +00:00, 44 | # } 45 | def recurring_tasks 46 | if supports_recurring_tasks? 47 | raise_incompatible_adapter_error_from :recurring_tasks 48 | end 49 | end 50 | 51 | # Returns a recurring task represented by a hash as indicated above 52 | def find_recurring_task(recurring_task_id) 53 | if supports_recurring_tasks? 54 | raise_incompatible_adapter_error_from :find_recurring_task 55 | end 56 | end 57 | 58 | 59 | # Returns an array with the list of workers. Each worker is represented as a hash 60 | # with these attributes: 61 | # { 62 | # id: 123, 63 | # name: "worker-name", 64 | # hostname: "hey-default-101", 65 | # last_heartbeat_at: Fri, 26 Jan 2024 20:31:09.652174000 UTC +00:00, 66 | # configuration: { ... } 67 | # raw_data: { ... } 68 | # } 69 | def workers 70 | if exposes_workers? 71 | raise_incompatible_adapter_error_from :workers 72 | end 73 | end 74 | 75 | # Returns a worker represented by a hash as indicated above 76 | def find_worker(worker_id) 77 | if exposes_workers? 78 | raise_incompatible_adapter_error_from :find_worker 79 | end 80 | end 81 | 82 | 83 | # Returns an array with the list of queues. Each queue is represented as a hash 84 | # with these attributes: 85 | # { 86 | # name: "queue_name", 87 | # size: 1, 88 | # active: true 89 | # } 90 | def queues 91 | raise_incompatible_adapter_error_from :queue_names 92 | end 93 | 94 | def queue_size(queue_name) 95 | raise_incompatible_adapter_error_from :queue_size 96 | end 97 | 98 | def clear_queue(queue_name) 99 | raise_incompatible_adapter_error_from :clear_queue 100 | end 101 | 102 | def pause_queue(queue_name) 103 | if supports_queue_pausing? 104 | raise_incompatible_adapter_error_from :pause_queue 105 | end 106 | end 107 | 108 | def resume_queue(queue_name) 109 | if supports_queue_pausing? 110 | raise_incompatible_adapter_error_from :resume_queue 111 | end 112 | end 113 | 114 | def queue_paused?(queue_name) 115 | if supports_queue_pausing? 116 | raise_incompatible_adapter_error_from :queue_paused? 117 | end 118 | end 119 | 120 | def jobs_count(jobs_relation) 121 | raise_incompatible_adapter_error_from :jobs_count 122 | end 123 | 124 | def fetch_jobs(jobs_relation) 125 | raise_incompatible_adapter_error_from :fetch_jobs 126 | end 127 | 128 | def retry_all_jobs(jobs_relation) 129 | raise_incompatible_adapter_error_from :retry_all_jobs 130 | end 131 | 132 | def retry_job(job, jobs_relation) 133 | raise_incompatible_adapter_error_from :retry_job 134 | end 135 | 136 | def discard_all_jobs(jobs_relation) 137 | raise_incompatible_adapter_error_from :discard_all_jobs 138 | end 139 | 140 | def discard_job(job, jobs_relation) 141 | raise_incompatible_adapter_error_from :discard_job 142 | end 143 | 144 | def dispatch_job(job, jobs_relation) 145 | raise_incompatible_adapter_error_from :dispatch_job 146 | end 147 | 148 | def find_job(job_id, *) 149 | raise_incompatible_adapter_error_from :find_job 150 | end 151 | 152 | private 153 | def raise_incompatible_adapter_error_from(method_name) 154 | raise MissionControl::Jobs::Errors::IncompatibleAdapter, "Adapter #{ActiveJob.adapter_name(self)} must implement `#{method_name}`" 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20230914113326_create_solid_queue_tables.solid_queue.rb: -------------------------------------------------------------------------------- 1 | class CreateSolidQueueTables < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :solid_queue_jobs do |t| 4 | t.string :queue_name, null: false 5 | t.string :class_name, null: false, index: true 6 | t.text :arguments 7 | t.integer :priority, default: 0, null: false 8 | t.string :active_job_id, index: true 9 | t.datetime :scheduled_at 10 | t.datetime :finished_at, index: true 11 | t.string :concurrency_key 12 | 13 | t.timestamps 14 | 15 | t.index [ :queue_name, :finished_at ], name: "index_solid_queue_jobs_for_filtering" 16 | t.index [ :scheduled_at, :finished_at ], name: "index_solid_queue_jobs_for_alerting" 17 | end 18 | 19 | create_table :solid_queue_scheduled_executions do |t| 20 | t.references :job, index: { unique: true }, null: false 21 | t.string :queue_name, null: false 22 | t.integer :priority, default: 0, null: false 23 | t.datetime :scheduled_at, null: false 24 | 25 | t.datetime :created_at, null: false 26 | 27 | t.index [ :scheduled_at, :priority, :job_id ], name: "index_solid_queue_dispatch_all" 28 | end 29 | 30 | create_table :solid_queue_ready_executions do |t| 31 | t.references :job, index: { unique: true }, null: false 32 | t.string :queue_name, null: false 33 | t.integer :priority, default: 0, null: false 34 | 35 | t.datetime :created_at, null: false 36 | 37 | t.index [ :priority, :job_id ], name: "index_solid_queue_poll_all" 38 | t.index [ :queue_name, :priority, :job_id ], name: "index_solid_queue_poll_by_queue" 39 | end 40 | 41 | create_table :solid_queue_claimed_executions do |t| 42 | t.references :job, index: { unique: true }, null: false 43 | t.bigint :process_id 44 | t.datetime :created_at, null: false 45 | 46 | t.index [ :process_id, :job_id ] 47 | end 48 | 49 | create_table :solid_queue_blocked_executions do |t| 50 | t.references :job, index: { unique: true }, null: false 51 | t.string :queue_name, null: false 52 | t.integer :priority, default: 0, null: false 53 | t.string :concurrency_key, null: false 54 | t.datetime :expires_at, null: false 55 | 56 | t.datetime :created_at, null: false 57 | 58 | t.index [ :expires_at, :concurrency_key ], name: "index_solid_queue_blocked_executions_for_maintenance" 59 | t.index [ :concurrency_key, :priority, :job_id ], name: "index_solid_queue_blocked_executions_for_release" 60 | end 61 | 62 | create_table :solid_queue_failed_executions do |t| 63 | t.references :job, index: { unique: true }, null: false 64 | t.text :error 65 | t.datetime :created_at, null: false 66 | end 67 | 68 | create_table :solid_queue_pauses do |t| 69 | t.string :queue_name, null: false, index: { unique: true } 70 | t.datetime :created_at, null: false 71 | end 72 | 73 | create_table :solid_queue_processes do |t| 74 | t.string :kind, null: false 75 | t.datetime :last_heartbeat_at, null: false, index: true 76 | t.bigint :supervisor_id, index: true 77 | 78 | t.integer :pid, null: false 79 | t.string :hostname 80 | t.text :metadata 81 | 82 | t.datetime :created_at, null: false 83 | end 84 | 85 | create_table :solid_queue_semaphores do |t| 86 | t.string :key, null: false, index: { unique: true } 87 | t.integer :value, default: 1, null: false 88 | t.datetime :expires_at, null: false, index: true 89 | 90 | t.timestamps 91 | 92 | t.index [ :key, :value ], name: "index_solid_queue_semaphores_on_key_and_value" 93 | end 94 | 95 | create_table :solid_queue_recurring_executions do |t| 96 | t.references :job, index: { unique: true }, null: false 97 | t.string :task_key, null: false 98 | t.datetime :run_at, null: false 99 | t.datetime :created_at, null: false 100 | 101 | t.index [ :task_key, :run_at ], unique: true 102 | end 103 | 104 | add_foreign_key :solid_queue_blocked_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade 105 | add_foreign_key :solid_queue_claimed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade 106 | add_foreign_key :solid_queue_failed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade 107 | add_foreign_key :solid_queue_ready_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade 108 | add_foreign_key :solid_queue_recurring_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade 109 | add_foreign_key :solid_queue_scheduled_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade 110 | end 111 | end 112 | --------------------------------------------------------------------------------