Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <role>, writing: <role> }`, 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
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions app/controllers/solid_queue_web/recurring_tasks/runs_controller.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions app/views/solid_queue_web/recurring_tasks/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<th scope="col">Next Run</th>
<th scope="col">Last Run</th>
<th scope="col">Type</th>
<th scope="col"><span class="sqd-sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
Expand Down Expand Up @@ -60,6 +61,12 @@
<span class="sqd-badge sqd-badge--dynamic">Dynamic</span>
<% end %>
</td>
<td class="sqd-row-actions">
<%= 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?" } %>
</td>
</tr>
<% end %>
</tbody>
Expand Down
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions spec/dummy/db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
63 changes: 63 additions & 0 deletions spec/requests/solid_queue_web/recurring_task_runs_spec.rb
Original file line number Diff line number Diff line change
@@ -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