diff --git a/CHANGELOG.md b/CHANGELOG.md index 3159558..c6c2a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Slow job webhook alert — set `alert_slow_job_count_threshold` (integer) to fire a webhook whenever the number of currently-running slow jobs meets or exceeds the configured count; requires `slow_job_threshold` to define what "slow" means; uses the same `alert_webhook_url` and `alert_webhook_cooldown` settings as other alert types; event name `slow_job_threshold_exceeded` - Stale process webhook alert — set `alert_stale_process_threshold` (integer) to fire a webhook whenever the number of stale workers meets or exceeds the configured count; a process is stale when its heartbeat has not been updated within `SolidQueue.process_alive_threshold`; uses the same `alert_webhook_url` and `alert_webhook_cooldown` settings; event name `stale_process_detected` - Job wait time column — the Running tab now shows a "Wait Time" column displaying how long each claimed job waited in the queue from enqueue to pickup; also included as `wait_time_seconds` in the CSV export for the claimed status +- Eliminate N+1 queries on the queues index — `queue.size` and `queue.paused?` previously fired one query each per queue; both are now pre-computed in the controller with single batch aggregations (`GROUP BY queue_name` for sizes, one `Pause` query for paused state), reducing query count from O(2N+constant) to O(constant) regardless of queue count ## [1.3.0] - 2026-05-27 diff --git a/ROADMAP.md b/ROADMAP.md index 993f312..5224b1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,7 +17,7 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib | ~~**Slow job webhook alert**~~ | ✅ Shipped in v1.4 — `alert_slow_job_count_threshold` fires when slow-job count meets or exceeds the threshold. | | ~~**Process stale webhook alert**~~ | ✅ Shipped in v1.4 — `alert_stale_process_threshold` fires when stale worker count meets or exceeds the threshold. | | ~~**Job wait time column**~~ | ✅ Shipped in v1.4 — "Wait Time" column on the Running tab; `wait_time_seconds` in the claimed CSV export. | -| **Eliminate N+1 queries** | Queues index fires 2 extra queries per queue: `queue.size` (COUNT per queue) and `queue.paused?` (EXISTS per queue). Replace with single batch aggregations pre-computed in the controller. | +| ~~**Eliminate N+1 queries**~~ | ✅ Shipped in v1.4 — queue size and paused state now pre-computed with single batch aggregations; query count reduced from O(2N) to O(1) per page load. | --- diff --git a/app/controllers/solid_queue_web/queues_controller.rb b/app/controllers/solid_queue_web/queues_controller.rb index f8909ba..bd91a32 100644 --- a/app/controllers/solid_queue_web/queues_controller.rb +++ b/app/controllers/solid_queue_web/queues_controller.rb @@ -7,6 +7,11 @@ def index @failed_24h = stats.failed_24h @oldest_ready = stats.oldest_ready @failure_sparklines = stats.failure_sparklines + @queue_sizes = SolidQueue::ReadyExecution + .joins(:job) + .group("solid_queue_jobs.queue_name") + .count + @paused_queue_names = SolidQueue::Pause.pluck(:queue_name).to_set end end end diff --git a/app/views/solid_queue_web/queues/index.html.erb b/app/views/solid_queue_web/queues/index.html.erb index 3abc8d8..2b21229 100644 --- a/app/views/solid_queue_web/queues/index.html.erb +++ b/app/views/solid_queue_web/queues/index.html.erb @@ -21,7 +21,7 @@ <% @queues.each do |queue| %> <%= queue.name %> - <%= queue.size %> + <%= @queue_sizes[queue.name] || 0 %> <% if (oldest = @oldest_ready[queue.name]) %> <% age = Time.current - oldest %> @@ -52,14 +52,14 @@ <% end %> - <% if queue.paused? %> + <% if @paused_queue_names.include?(queue.name) %> Paused <% else %> Running <% end %> - <% if queue.paused? %> + <% if @paused_queue_names.include?(queue.name) %> <%= button_to "Resume", queue_pause_path(queue.name), method: :delete, class: "sqd-btn sqd-btn--primary sqd-btn--sm" %> <% else %> diff --git a/spec/requests/solid_queue_web/queues_spec.rb b/spec/requests/solid_queue_web/queues_spec.rb index 082e9b1..100724c 100644 --- a/spec/requests/solid_queue_web/queues_spec.rb +++ b/spec/requests/solid_queue_web/queues_spec.rb @@ -68,6 +68,48 @@ end end + describe "GET /jobs/queues — batch-loaded queue size and paused state" do + it "shows the ready job count for a queue" do + job = SolidQueue::Job.create!( + queue_name: "default", class_name: "TestJob", + arguments: {}, active_job_id: SecureRandom.uuid + ) + job.ready_execution + get "/jobs/queues" + expect(response.body).to match(/\s*\d+\s*<\/td>/) + end + + it "shows 0 size for a queue with no ready jobs" do + SolidQueue::ReadyExecution.delete_all + get "/jobs/queues" + expect(response.body).to include("0") + end + + it "shows the Paused badge for a paused queue" do + SolidQueue::Pause.create!(queue_name: "default") + get "/jobs/queues" + expect(response.body).to include("sqd-badge--paused") + expect(response.body).to include("Paused") + end + + it "shows the Running badge for an unpaused queue" do + get "/jobs/queues" + expect(response.body).to include("sqd-badge--running") + expect(response.body).to include("Running") + end + + it "shows Resume button for a paused queue" do + SolidQueue::Pause.create!(queue_name: "default") + get "/jobs/queues" + expect(response.body).to include("Resume") + end + + it "shows Pause button for a running queue" do + get "/jobs/queues" + expect(response.body).to include("Pause") + end + end + describe "POST /jobs/queues/:name/pause" do it "pauses the queue and redirects" do post "/jobs/queues/default/pause"