diff --git a/CHANGELOG.md b/CHANGELOG.md index f612b10..7ec44e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Recurring task "Run Now" — a "Run Now" button on the Recurring Tasks page triggers `task.enqueue(at: Time.current)` to enqueue the job immediately without waiting for its next scheduled run; SolidQueue's `RecurringExecution` deduplication prevents double-enqueuing - Read replica support — when `connects_to` is set to `{ reading: , writing: }`, the engine automatically routes GET requests to the reading role and mutating requests (POST/DELETE/PATCH) to the writing role via `ActiveRecord::Base.connected_to(role:)`; passing any other hash (e.g. `{ role: :writing }`, `{ shard: :name }`) falls through to `connected_to` directly; defaults to `nil` so single-database setups are unaffected - Webhook alert config — `alert_webhook_url` and `alert_failure_threshold` settings POST a JSON payload (`event`, `failure_count`, `threshold`, `fired_at`) to any URL when the failed job count meets or exceeds the threshold; fires asynchronously in a background thread so dashboard requests are never blocked; a configurable `alert_webhook_cooldown` (default 3600 s) prevents repeated alerts while the count stays elevated; HTTP errors are logged and swallowed - Bulk retry with delay — "+5s", "+10s", "+30s", and "+1m" stagger buttons on the Failed Jobs page retry all matched jobs with a configurable interval between each; the first job runs immediately, subsequent jobs are scheduled at incremental offsets; uses per-execution `retry` so `scheduled_at` is respected by SolidQueue's dispatcher; buttons only appear when more than one job is present diff --git a/README.md b/README.md index 1f2f456..01e248b 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch - **Failed jobs** — list of failed executions with error details; search by class name; filter by queue; time-based period filter; retry or discard individually or in bulk; bulk retry with configurable stagger (+5s / +10s / +30s / +1m) to avoid thundering herd on recovery - **Job detail** — full arguments, timestamps, blocked-until date, and error backtrace; action buttons based on job status - **Queue management** — pause and resume individual queues; queue-scoped job list with status filter, search, and discard -- **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification +- **Recurring tasks** — all configured recurring tasks with cron schedule, next run time, last run time, and static/dynamic classification; "Run Now" button enqueues a task immediately without waiting for its next scheduled run - **Processes** — workers, dispatchers, and supervisors with heartbeat health status; auto-refreshes every 10 seconds - **Global search** — search across all job statuses at once by class name substring; results grouped by status with match count and direct links to filtered views; native datalist autocomplete pre-populated from all known job classes; auto-submits on selection - **Targeted bulk actions** — checkboxes on the jobs and failed jobs lists for selecting individual rows; selection bar shows count and action buttons ("Discard Selected" for jobs, "Retry Selected" / "Discard Selected" for failed jobs); select-all checkbox in the table header @@ -165,7 +165,6 @@ Planned features, roughly ordered by priority: **Operations** - Admin audit log — record who retried or discarded which jobs and when (requires host-app user identity) -- Recurring task "Run Now" — manually trigger a recurring task immediately without waiting for its next scheduled run - Failed job retry with modified arguments — edit the arguments JSON from the job detail page before retrying; useful for correcting bad payloads without redeploying - Bulk scheduled job actions — "Run All Now" button on the Scheduled tab, mirroring the "Retry All" pattern on the Failed Jobs page diff --git a/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb b/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb new file mode 100644 index 0000000..73461c4 --- /dev/null +++ b/app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb @@ -0,0 +1,18 @@ +module SolidQueueWeb + class RecurringTasks::RunsController < ApplicationController + def create + task = SolidQueue::RecurringTask.find_by!(key: params[:recurring_task_key]) + result = task.enqueue(at: Time.current) + + if result + redirect_to recurring_tasks_path, notice: "\"#{task.key}\" queued for immediate execution." + else + redirect_to recurring_tasks_path, alert: "Could not enqueue \"#{task.key}\" — it may have just run." + end + rescue ActiveRecord::RecordNotFound + redirect_to recurring_tasks_path, alert: "Recurring task not found." + rescue => e + redirect_to recurring_tasks_path, alert: "Could not run task: #{e.message}" + end + end +end diff --git a/app/views/solid_queue_web/recurring_tasks/index.html.erb b/app/views/solid_queue_web/recurring_tasks/index.html.erb index bd305cb..30d236f 100644 --- a/app/views/solid_queue_web/recurring_tasks/index.html.erb +++ b/app/views/solid_queue_web/recurring_tasks/index.html.erb @@ -14,6 +14,7 @@ Next Run Last Run Type + Actions @@ -60,6 +61,12 @@ Dynamic <% end %> + + <%= button_to "Run Now", recurring_task_run_path(task.key), + method: :post, + class: "sqd-btn sqd-btn--primary sqd-btn--sm", + data: { confirm: "Run \"#{task.key}\" immediately?" } %> + <% end %> diff --git a/config/routes.rb b/config/routes.rb index 325b428..3756d92 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,7 +5,9 @@ get "search", to: "search#index", as: :search get "history", to: "history#index", as: :history - resources :recurring_tasks, only: [:index] + resources :recurring_tasks, only: [:index], param: :key do + resource :run, only: [:create], controller: "recurring_tasks/runs" + end resources :processes, only: [:index] resources :queues, only: [:index], param: :name do resource :pause, only: [:create, :destroy], controller: "queues/pauses" diff --git a/spec/dummy/db/seeds.rb b/spec/dummy/db/seeds.rb index 9bb25f4..8fcaf24 100644 --- a/spec/dummy/db/seeds.rb +++ b/spec/dummy/db/seeds.rb @@ -20,6 +20,7 @@ solid_queue_blocked_executions solid_queue_ready_executions solid_queue_recurring_executions + solid_queue_recurring_tasks solid_queue_jobs solid_queue_processes solid_queue_semaphores @@ -208,6 +209,19 @@ end end +puts "Seeding recurring tasks..." +recurring_tasks_data = [ + { key: "nightly-cleanup", schedule: "0 2 * * *", command: "CleanupJob.perform_later", queue_name: "default", description: "Nightly database cleanup", static: true }, + { key: "hourly-data-sync", schedule: "0 * * * *", command: "DataSyncJob.perform_later", queue_name: "default", description: "Sync data from external APIs every hour", static: true }, + { key: "weekly-report", schedule: "0 8 * * 1", command: "ReportGeneratorJob.perform_later", queue_name: "low_priority", description: "Generate weekly summary report on Monday morning", static: true }, + { key: "send-digest-email", schedule: "0 9 * * *", command: "UserMailerJob.perform_later", queue_name: "mailers", description: "Daily digest email to all users", static: true }, + { key: "export-invoices", schedule: "30 1 1 * *", command: "InvoiceGeneratorJob.perform_later", queue_name: "critical", description: "Monthly invoice export on the 1st", static: true }, + { key: "purge-old-images", schedule: "0 3 * * 0", command: "ImageProcessingJob.perform_later", queue_name: "low_priority", description: "Weekly purge of unattached image uploads", static: true }, + { key: "dynamic-notification", schedule: "*/15 * * * *", command: "NotificationJob.perform_later", queue_name: "mailers", description: "Push notifications every 15 minutes", static: false } +] + +recurring_tasks_data.each { |attrs| SolidQueue::RecurringTask.create!(attrs) } + puts "Done! Created:" puts " #{SolidQueue::ReadyExecution.count} ready jobs" puts " #{SolidQueue::ScheduledExecution.count} scheduled jobs" @@ -216,3 +230,4 @@ puts " #{SolidQueue::FailedExecution.count} failed jobs" puts " #{SolidQueue::Process.count} processes" puts " #{SolidQueue::Job.where.not(finished_at: nil).count} finished jobs" +puts " #{SolidQueue::RecurringTask.count} recurring tasks" diff --git a/spec/requests/solid_queue_web/recurring_task_runs_spec.rb b/spec/requests/solid_queue_web/recurring_task_runs_spec.rb new file mode 100644 index 0000000..96e0a0c --- /dev/null +++ b/spec/requests/solid_queue_web/recurring_task_runs_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe "RecurringTasks::Runs", type: :request do + let!(:task) do + SolidQueue::RecurringTask.create!( + key: "cleanup-task", + schedule: "0 2 * * *", + command: "CleanupJob.perform_later" + ) + end + + before do + allow_any_instance_of(SolidQueue::RecurringTask).to receive(:enqueue) + .with(at: instance_of(ActiveSupport::TimeWithZone)) + .and_return(double("job", job_id: SecureRandom.uuid)) + end + + describe "POST /jobs/recurring_tasks/:key/run" do + it "redirects to the recurring tasks page" do + post "/jobs/recurring_tasks/cleanup-task/run" + expect(response).to redirect_to("/jobs/recurring_tasks") + end + + it "shows a success notice" do + post "/jobs/recurring_tasks/cleanup-task/run" + follow_redirect! + expect(response.body).to include("queued for immediate execution") + end + + it "includes the task key in the notice" do + post "/jobs/recurring_tasks/cleanup-task/run" + follow_redirect! + expect(response.body).to include("cleanup-task") + end + + it "renders a Run Now button on the index page" do + get "/jobs/recurring_tasks" + expect(response.body).to include("Run Now") + end + + it "returns 404 redirect for an unknown key" do + post "/jobs/recurring_tasks/nonexistent-task/run" + expect(response).to redirect_to("/jobs/recurring_tasks") + follow_redirect! + expect(response.body).to include("Recurring task not found") + end + + it "shows an alert when enqueue returns false" do + allow_any_instance_of(SolidQueue::RecurringTask).to receive(:enqueue).and_return(false) + post "/jobs/recurring_tasks/cleanup-task/run" + follow_redirect! + expect(response.body).to include("Could not enqueue") + end + + it "handles unexpected errors gracefully" do + allow_any_instance_of(SolidQueue::RecurringTask).to receive(:enqueue).and_raise(RuntimeError, "boom") + post "/jobs/recurring_tasks/cleanup-task/run" + expect(response).to redirect_to("/jobs/recurring_tasks") + follow_redirect! + expect(response.body).to include("Could not run task") + end + end +end