From 3f370c570298f0ef4cab5aa7c0a661e06365dd2f Mon Sep 17 00:00:00 2001 From: Mikhail Kutuzov Date: Wed, 7 Oct 2026 15:34:07 +0300 Subject: [PATCH] feat: run a single recurring task on demand Add a Run now action that enqueues one recurring task immediately without changing its schedule. --- README.md | 2 +- .../recurring_jobs_controller.rb | 6 +++ .../enqueue_recurring_task_service.rb | 28 ++++++++++ .../recurring_jobs/_row.html.erb | 9 ++++ .../recurring_jobs/index.html.erb | 3 +- config/routes.rb | 4 +- .../recurring_jobs_spec.rb | 54 ++++++++++++++++++- .../enqueue_recurring_task_service_spec.rb | 53 ++++++++++++++++++ spec/spec_helper.rb | 10 ++++ spec/support/solid_queue_stubs.rb | 23 ++++++++ 10 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 app/services/solid_queue_monitor/enqueue_recurring_task_service.rb create mode 100644 spec/services/solid_queue_monitor/enqueue_recurring_task_service_spec.rb diff --git a/README.md b/README.md index 2999ca7..bc2be4a 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ The dashboard provides several views: - **Overview**: Shows statistics, recent jobs, and job activity chart - **Ready Jobs**: Jobs that are ready to be executed - **Scheduled Jobs**: Jobs scheduled for future execution with execute and reject actions -- **Recurring Jobs**: Jobs that run on a recurring schedule +- **Recurring Jobs**: Jobs that run on a recurring schedule, with a Run now action that enqueues one task immediately without changing its schedule - **Failed Jobs**: Jobs that have failed with error details and retry/discard actions - **Queues**: Distribution of jobs across different queues with pause/resume controls - **Workers**: Real-time monitoring of all Solid Queue processes with health status diff --git a/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb b/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb index 21aaf7c..75cf5e6 100644 --- a/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb +++ b/app/controllers/solid_queue_monitor/recurring_jobs_controller.rb @@ -12,5 +12,11 @@ def index @sort = sort_params @action_path = recurring_jobs_path end + + def enqueue + result = SolidQueueMonitor::EnqueueRecurringTaskService.call(params[:id]) + set_flash_message(result[:message], result[:success] ? 'success' : 'error') + redirect_to recurring_jobs_path + end end end diff --git a/app/services/solid_queue_monitor/enqueue_recurring_task_service.rb b/app/services/solid_queue_monitor/enqueue_recurring_task_service.rb new file mode 100644 index 0000000..221064b --- /dev/null +++ b/app/services/solid_queue_monitor/enqueue_recurring_task_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SolidQueueMonitor + class EnqueueRecurringTaskService + class << self + def call(id) + task = SolidQueue::RecurringTask.find(id) + enqueued_job = task.enqueue(at: Time.current) + + if enqueued?(enqueued_job) + { success: true, message: "Recurring task '#{task.key}' has been queued to run now" } + else + { success: false, message: "Could not enqueue recurring task '#{task.key}'. It may already be queued for this time" } + end + rescue ActiveRecord::RecordNotFound + { success: false, message: 'Recurring task not found' } + rescue StandardError => e + { success: false, message: "Failed to enqueue recurring task: #{e.message}" } + end + + private + + def enqueued?(enqueued_job) + enqueued_job.respond_to?(:successfully_enqueued?) && enqueued_job.successfully_enqueued? + end + end + end +end diff --git a/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb b/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb index b1856dc..e267a9d 100644 --- a/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb +++ b/app/views/solid_queue_monitor/recurring_jobs/_row.html.erb @@ -5,4 +5,13 @@ <%= queue_link(job.queue_name) %> <%= job.priority || 'Default' %> <%= format_datetime(job.updated_at) %> + +
+ <%= csrf_token_field_if_enabled %> + +
+ diff --git a/app/views/solid_queue_monitor/recurring_jobs/index.html.erb b/app/views/solid_queue_monitor/recurring_jobs/index.html.erb index 21ffcdb..c065aed 100644 --- a/app/views/solid_queue_monitor/recurring_jobs/index.html.erb +++ b/app/views/solid_queue_monitor/recurring_jobs/index.html.erb @@ -11,7 +11,8 @@ { sort_key: nil, label: 'Schedule' }, { sort_key: :queue_name, label: 'Queue' }, { sort_key: :priority, label: 'Priority' }, - { sort_key: nil, label: 'Last Updated' } + { sort_key: nil, label: 'Last Updated' }, + { sort_key: nil, label: 'Actions' } ] %> <%= render 'solid_queue_monitor/shared/jobs_table', diff --git a/config/routes.rb b/config/routes.rb index 7c2866b..1e35c6b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,7 +11,9 @@ resources :ready_jobs, only: [:index] resources :scheduled_jobs, only: [:index] - resources :recurring_jobs, only: [:index] + resources :recurring_jobs, only: [:index] do + post :enqueue, on: :member + end resources :failed_jobs, only: [:index] resources :in_progress_jobs, only: [:index] resources :queues, only: [:index] diff --git a/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb b/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb index d0a912e..3dd884b 100644 --- a/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb +++ b/spec/requests/solid_queue_monitor/recurring_jobs_spec.rb @@ -4,13 +4,18 @@ RSpec.describe 'Recurring Jobs' do describe 'GET /recurring_jobs' do - let!(:recurring_task1) do + let(:recurring_task1) do create(:solid_queue_recurring_task, key: 'daily_cleanup', class_name: 'DailyCleanupJob') end - let!(:recurring_task2) do + let(:recurring_task2) do create(:solid_queue_recurring_task, key: 'hourly_metrics', class_name: 'HourlyMetricsJob') end + before do + recurring_task1 + recurring_task2 + end + it 'returns a successful response' do get '/recurring_jobs' @@ -28,6 +33,7 @@ expect(response.body).to include('daily_cleanup') expect(response.body).to include('DailyCleanupJob') + expect(response.body).to include('Run now') end context 'with no recurring tasks' do @@ -51,4 +57,48 @@ end end end + + describe 'POST /recurring_jobs/:id/enqueue' do + include ActiveSupport::Testing::TimeHelpers + + let(:recurring_task) do + create(:solid_queue_recurring_task, key: 'daily_cleanup', class_name: 'SolidQueueMonitorEnqueueProbeJob') + end + + context 'when the task exists' do + before { recurring_task } + + it 'enqueues the task immediately and redirects' do + expect do + post enqueue_recurring_job_path(recurring_task) + end.to change(SolidQueue::Job, :count).by(1) + .and change(SolidQueue::ReadyExecution, :count).by(1) + + expect(response).to redirect_to('/recurring_jobs') + + job = SolidQueue::Job.last + expect(job.class_name).to eq('SolidQueueMonitorEnqueueProbeJob') + expect(job.queue_name).to eq(recurring_task.queue_name) + expect(recurring_task.reload.schedule).to eq('0 * * * *') + end + + it 'does not enqueue a second copy for the same timestamp' do + travel_to Time.zone.local(2026, 10, 7, 12, 0, 0) do + post enqueue_recurring_job_path(recurring_task) + + expect do + post enqueue_recurring_job_path(recurring_task) + end.not_to change(SolidQueue::Job, :count) + end + + expect(response).to redirect_to('/recurring_jobs') + end + end + + it 'redirects with an error when the task does not exist' do + post enqueue_recurring_job_path(0) + + expect(response).to redirect_to('/recurring_jobs') + end + end end diff --git a/spec/services/solid_queue_monitor/enqueue_recurring_task_service_spec.rb b/spec/services/solid_queue_monitor/enqueue_recurring_task_service_spec.rb new file mode 100644 index 0000000..7c736a6 --- /dev/null +++ b/spec/services/solid_queue_monitor/enqueue_recurring_task_service_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SolidQueueMonitor::EnqueueRecurringTaskService do + describe '.call' do + let(:recurring_task) do + create(:solid_queue_recurring_task, + key: 'hourly_metrics', + class_name: 'SolidQueueMonitorEnqueueProbeJob', + queue_name: 'metrics', + priority: 3) + end + + context 'when the task exists' do + before { recurring_task } + + it 'enqueues the recurring task into the ready queue' do + result = nil + + expect do + result = described_class.call(recurring_task.id) + end.to change(SolidQueue::ReadyExecution, :count).by(1) + + expect(result[:success]).to be(true) + expect(result[:message]).to include('hourly_metrics') + + ready_execution = SolidQueue::ReadyExecution.last + expect(ready_execution.queue_name).to eq('metrics') + expect(ready_execution.priority).to eq(3) + expect(ready_execution.job.class_name).to eq('SolidQueueMonitorEnqueueProbeJob') + end + + it 'returns an error when enqueue fails' do + task = SolidQueue::RecurringTask.find(recurring_task.id) + allow(SolidQueue::RecurringTask).to receive(:find).and_return(task) + allow(task).to receive(:enqueue).and_raise(StandardError, 'missing job class') + + result = described_class.call(recurring_task.id) + + expect(result[:success]).to be(false) + expect(result[:message]).to include('missing job class') + end + end + + it 'returns an error when the task does not exist' do + result = described_class.call(0) + + expect(result[:success]).to be(false) + expect(result[:message]).to eq('Recurring task not found') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6003b04..61eacd6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -64,6 +64,16 @@ t.datetime :created_at, null: false end + create_table :solid_queue_recurring_executions, force: true do |t| + t.integer :job_id, null: false + t.string :task_key, null: false + t.datetime :run_at, null: false + t.datetime :created_at, null: false + end + + add_index :solid_queue_recurring_executions, :job_id, unique: true + add_index :solid_queue_recurring_executions, %i[task_key run_at], unique: true + create_table :solid_queue_recurring_tasks, force: true do |t| t.string :key, null: false t.string :schedule, null: false diff --git a/spec/support/solid_queue_stubs.rb b/spec/support/solid_queue_stubs.rb index 9a8d4fe..5458488 100644 --- a/spec/support/solid_queue_stubs.rb +++ b/spec/support/solid_queue_stubs.rb @@ -60,8 +60,31 @@ class Pause < ApplicationRecord self.table_name = 'solid_queue_pauses' end + class RecurringExecution < ApplicationRecord + self.table_name = 'solid_queue_recurring_executions' + end + class RecurringTask < ApplicationRecord self.table_name = 'solid_queue_recurring_tasks' + + # Mirrors SolidQueue::RecurringTask#enqueue for the test double. + # The real model records one execution per task and timestamp, then + # places the job on the ready queue immediately. + def enqueue(at:) + return false if RecurringExecution.exists?(task_key: key, run_at: at) + + job = Job.create!( + queue_name: queue_name.presence || 'default', + class_name: class_name.presence || 'SolidQueue::RecurringJob', + arguments: arguments, + priority: priority || 0, + active_job_id: SecureRandom.uuid + ) + ReadyExecution.create!(job: job, queue_name: job.queue_name, priority: job.priority) + RecurringExecution.create!(job_id: job.id, task_key: key, run_at: at) + + Struct.new(:successfully_enqueued?).new(true) + end end class Process < ApplicationRecord