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 |
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 |
--------------------------------------------------------------------------------