Skip to content

Commit c9039bf

Browse files
authored
release: v1.2.0 — Performance at Scale (#28)
* perf: derive overview stats from execution tables, not jobs table Replace COUNT(*) queries on solid_queue_jobs with counts from ready_executions, scheduled_executions, claimed_executions, and failed_executions. Replaces "Total Jobs" and "Completed" dashboard stats with "Active Jobs" (sum of ready + scheduled + in-progress + failed). Resolves gateway timeouts on overview page with millions of rows. Fixes #27 * perf: replace unbounded pluck with subqueries and fix N+1 queue stats - Change .pluck(:job_id) to .select(:job_id) in all filter methods to keep filtering as DB subqueries instead of loading IDs into memory - Pre-aggregate queue stats with 3 GROUP BY queries, eliminating per-queue COUNT queries in QueuesPresenter - Add spec to prevent unbounded pluck regression * perf: use SQL GROUP BY bucketing for chart data Replace in-memory timestamp bucketing (pluck all timestamps, iterate in Ruby) with SQL GROUP BY using computed bucket index. Works on both PostgreSQL and SQLite with adapter-aware expressions. * feat: add config.show_chart toggle to disable chart queries When show_chart is false, ChartDataService is not instantiated and the chart section is not rendered, eliminating chart queries entirely for users who don't need the visualization. * docs: document performance optimizations and show_chart config - Add "Performance at Scale" section to README - Add [Unreleased] section to CHANGELOG with breaking change note - Add Large Dataset Performance to ROADMAP as done - Include implementation plan in docs/plans/ * release: v1.2.0 — Performance at Scale Bump version to 1.2.0, set CHANGELOG release date, update README gem version reference. * fix: resolve rubocop and CI lint offenses - Fix RSpec/MessageSpies: use have_received instead of receive - Fix Layout/HashAlignment, Layout/ArgumentAlignment auto-corrections - Fix Style/ConditionalAssignment in base_controller - Disable false-positive Style/HashTransformKeys (pluck returns Array) - Disable RSpec/DescribeClass for source-scanning spec - Update Gemfile.lock with version 1.2.0
1 parent 2ffbfb3 commit c9039bf

21 files changed

Lines changed: 1338 additions & 268 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Changelog
22

3+
## [1.2.0] - 2026-03-07
4+
5+
### Changed
6+
7+
- **BREAKING**: Dashboard "Total Jobs" and "Completed" stats replaced with "Active Jobs" (sum of ready + scheduled + in-progress + failed). This avoids expensive `COUNT(*)` on the jobs table at scale.
8+
9+
### Fixed
10+
11+
- **Performance**: Overview page no longer queries `solid_queue_jobs` for stats — all counts derived from execution tables (resolves gateway timeouts with millions of rows) ([#27](https://github.com/vishaltps/solid_queue_monitor/issues/27))
12+
- **Performance**: Chart data service uses SQL `GROUP BY` bucketing instead of loading all timestamps into Ruby memory
13+
- **Performance**: All filter methods use `.select(:job_id)` subqueries instead of unbounded `.pluck(:job_id)`
14+
- **Performance**: Queue stats pre-aggregated with 3 `GROUP BY` queries, eliminating N+1 per-queue COUNT queries
15+
16+
### Added
17+
18+
- `config.show_chart` option to disable the job activity chart and skip chart queries entirely
19+
320
## [1.1.0] - 2026-02-07
421

522
### Added

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_queue_monitor (1.1.0)
4+
solid_queue_monitor (1.2.0)
55
rails (>= 7.0)
66
solid_queue (>= 0.1.0)
77

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ A lightweight, zero-dependency web interface for monitoring Solid Queue backgrou
7272
Add this line to your application's Gemfile:
7373

7474
```ruby
75-
gem 'solid_queue_monitor', '~> 1.1'
75+
gem 'solid_queue_monitor', '~> 1.2'
7676
```
7777

7878
Then execute:
@@ -118,9 +118,27 @@ SolidQueueMonitor.setup do |config|
118118

119119
# Auto-refresh interval in seconds (default: 30)
120120
config.auto_refresh_interval = 30
121+
122+
# Disable the chart on the overview page to skip chart queries entirely
123+
# config.show_chart = true
121124
end
122125
```
123126

127+
### Performance at Scale
128+
129+
SolidQueueMonitor is optimized for large datasets (millions of rows in `solid_queue_jobs`):
130+
131+
- **Overview stats** are derived entirely from execution tables (`ready_executions`, `scheduled_executions`, `claimed_executions`, `failed_executions`), avoiding expensive `COUNT(*)` queries on the jobs table.
132+
- **Chart data** uses SQL `GROUP BY` bucketing instead of loading timestamps into Ruby memory.
133+
- **Filters** use subqueries (`.select(:job_id)`) instead of loading ID arrays into memory.
134+
- **Queue stats** are pre-aggregated with `GROUP BY` to avoid N+1 queries.
135+
136+
If you don't need the job activity chart, disable it to skip chart queries entirely:
137+
138+
```ruby
139+
config.show_chart = false
140+
```
141+
124142
### Authentication
125143

126144
By default, Solid Queue Monitor does not require authentication to access the dashboard. This makes it easy to get started in development environments.

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ This document tracks planned features for solid_queue_monitor, comparing with ot
2222
| Job Details Page | Dedicated page for single job with full context | ✅ Done |
2323
| Search/Full-text Search | Better search across all job data | ✅ Done |
2424
| Sorting Options | Sort by various columns | ✅ Done |
25+
| Large Dataset Performance | Optimized for millions of rows — no jobs table scans | ✅ Done |
2526
| Backtrace Cleaner | Remove framework noise from error backtraces | ⬚ Planned |
2627
| Manual Job Triggering | Enqueue a job directly from the dashboard | ⬚ Planned |
2728
| Cancel Running Jobs | Stop long-running jobs | ⬚ Planned |

app/controllers/solid_queue_monitor/base_controller.rb

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,13 @@ def filter_jobs(relation)
9191
when 'completed'
9292
relation = relation.where.not(finished_at: nil)
9393
when 'failed'
94-
failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id)
95-
relation = relation.where(id: failed_job_ids)
94+
relation = relation.where(id: SolidQueue::FailedExecution.select(:job_id))
9695
when 'scheduled'
97-
scheduled_job_ids = SolidQueue::ScheduledExecution.pluck(:job_id)
98-
relation = relation.where(id: scheduled_job_ids)
96+
relation = relation.where(id: SolidQueue::ScheduledExecution.select(:job_id))
9997
when 'pending'
100-
# Pending jobs are those that are not completed, failed, or scheduled
101-
failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id)
102-
scheduled_job_ids = SolidQueue::ScheduledExecution.pluck(:job_id)
10398
relation = relation.where(finished_at: nil)
104-
.where.not(id: failed_job_ids + scheduled_job_ids)
99+
.where.not(id: SolidQueue::FailedExecution.select(:job_id))
100+
.where.not(id: SolidQueue::ScheduledExecution.select(:job_id))
105101
end
106102
end
107103

@@ -117,16 +113,13 @@ def filter_ready_jobs(relation)
117113
return relation unless params[:class_name].present? || params[:queue_name].present? || params[:arguments].present?
118114

119115
if params[:class_name].present?
120-
job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id)
121-
relation = relation.where(job_id: job_ids)
116+
relation = relation.where(job_id: SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").select(:id))
122117
end
123118

124119
relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%") if params[:queue_name].present?
125120

126-
# Add arguments filtering
127121
if params[:arguments].present?
128-
job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id)
129-
relation = relation.where(job_id: job_ids)
122+
relation = relation.where(job_id: SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").select(:id))
130123
end
131124

132125
relation
@@ -136,16 +129,13 @@ def filter_scheduled_jobs(relation)
136129
return relation unless params[:class_name].present? || params[:queue_name].present? || params[:arguments].present?
137130

138131
if params[:class_name].present?
139-
job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id)
140-
relation = relation.where(job_id: job_ids)
132+
relation = relation.where(job_id: SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").select(:id))
141133
end
142134

143135
relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%") if params[:queue_name].present?
144136

145-
# Add arguments filtering
146137
if params[:arguments].present?
147-
job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id)
148-
relation = relation.where(job_id: job_ids)
138+
relation = relation.where(job_id: SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").select(:id))
149139
end
150140

151141
relation
@@ -170,25 +160,19 @@ def filter_failed_jobs(relation)
170160
return relation unless params[:class_name].present? || params[:queue_name].present? || params[:arguments].present?
171161

172162
if params[:class_name].present?
173-
job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id)
174-
relation = relation.where(job_id: job_ids)
163+
relation = relation.where(job_id: SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").select(:id))
175164
end
176165

177166
if params[:queue_name].present?
178-
# Check if FailedExecution has queue_name column
179-
if relation.column_names.include?('queue_name')
180-
relation = relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%")
181-
else
182-
# If not, filter by job's queue_name
183-
job_ids = SolidQueue::Job.where('queue_name LIKE ?', "%#{params[:queue_name]}%").pluck(:id)
184-
relation = relation.where(job_id: job_ids)
185-
end
167+
relation = if relation.column_names.include?('queue_name')
168+
relation.where('queue_name LIKE ?', "%#{params[:queue_name]}%")
169+
else
170+
relation.where(job_id: SolidQueue::Job.where('queue_name LIKE ?', "%#{params[:queue_name]}%").select(:id))
171+
end
186172
end
187173

188-
# Add arguments filtering
189174
if params[:arguments].present?
190-
job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id)
191-
relation = relation.where(job_id: job_ids)
175+
relation = relation.where(job_id: SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").select(:id))
192176
end
193177

194178
relation

app/controllers/solid_queue_monitor/in_progress_jobs_controller.rb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,11 @@ def filter_in_progress_jobs(relation)
2222
return relation if params[:class_name].blank? && params[:arguments].blank?
2323

2424
if params[:class_name].present?
25-
job_ids = SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").pluck(:id)
26-
relation = relation.where(job_id: job_ids)
25+
relation = relation.where(job_id: SolidQueue::Job.where('class_name LIKE ?', "%#{params[:class_name]}%").select(:id))
2726
end
2827

2928
if params[:arguments].present?
30-
job_ids = SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").pluck(:id)
31-
relation = relation.where(job_id: job_ids)
29+
relation = relation.where(job_id: SolidQueue::Job.where('arguments::text ILIKE ?', "%#{params[:arguments]}%").select(:id))
3230
end
3331

3432
relation

app/controllers/solid_queue_monitor/overview_controller.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class OverviewController < BaseController
66

77
def index
88
@stats = SolidQueueMonitor::StatsCalculator.calculate
9-
@chart_data = SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate
9+
@chart_data = SolidQueueMonitor.show_chart ? SolidQueueMonitor::ChartDataService.new(time_range: time_range_param).calculate : nil
1010

1111
recent_jobs_query = SolidQueue::Job.limit(100)
1212
sorted_query = apply_sorting(filter_jobs(recent_jobs_query), SORTABLE_COLUMNS, 'created_at', :desc)
@@ -29,13 +29,13 @@ def time_range_param
2929
end
3030

3131
def generate_overview_content
32-
SolidQueueMonitor::StatsPresenter.new(@stats).render +
33-
SolidQueueMonitor::ChartPresenter.new(@chart_data).render +
34-
SolidQueueMonitor::JobsPresenter.new(@recent_jobs[:records],
35-
current_page: @recent_jobs[:current_page],
36-
total_pages: @recent_jobs[:total_pages],
37-
filters: filter_params,
38-
sort: sort_params).render
32+
html = SolidQueueMonitor::StatsPresenter.new(@stats).render
33+
html += SolidQueueMonitor::ChartPresenter.new(@chart_data).render if @chart_data
34+
html + SolidQueueMonitor::JobsPresenter.new(@recent_jobs[:records],
35+
current_page: @recent_jobs[:current_page],
36+
total_pages: @recent_jobs[:total_pages],
37+
filters: filter_params,
38+
sort: sort_params).render
3939
end
4040
end
4141
end

app/controllers/solid_queue_monitor/queues_controller.rb

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ def index
1010
.select('queue_name, COUNT(*) as job_count')
1111
@queues = apply_queue_sorting(base_query)
1212
@paused_queues = QueuePauseService.paused_queues
13+
@queue_stats = aggregate_queue_stats
1314

14-
render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(@queues, @paused_queues, sort: sort_params).render)
15+
render_page('Queues', SolidQueueMonitor::QueuesPresenter.new(
16+
@queues, @paused_queues,
17+
queue_stats: @queue_stats,
18+
sort: sort_params
19+
).render)
1520
end
1621

1722
def show
@@ -57,6 +62,15 @@ def resume
5762

5863
private
5964

65+
def aggregate_queue_stats
66+
{
67+
ready: SolidQueue::ReadyExecution.group(:queue_name).count,
68+
scheduled: SolidQueue::ScheduledExecution.group(:queue_name).count,
69+
failed: SolidQueue::FailedExecution.joins(:job)
70+
.group('solid_queue_jobs.queue_name').count
71+
}
72+
end
73+
6074
def calculate_queue_counts(queue_name)
6175
{
6276
total: SolidQueue::Job.where(queue_name: queue_name).count,
@@ -77,17 +91,13 @@ def filter_queue_jobs(relation)
7791
when 'completed'
7892
relation = relation.where.not(finished_at: nil)
7993
when 'failed'
80-
failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id)
81-
relation = relation.where(id: failed_job_ids)
94+
relation = relation.where(id: SolidQueue::FailedExecution.select(:job_id))
8295
when 'scheduled'
83-
scheduled_job_ids = SolidQueue::ScheduledExecution.pluck(:job_id)
84-
relation = relation.where(id: scheduled_job_ids)
96+
relation = relation.where(id: SolidQueue::ScheduledExecution.select(:job_id))
8597
when 'pending'
86-
ready_job_ids = SolidQueue::ReadyExecution.pluck(:job_id)
87-
relation = relation.where(id: ready_job_ids)
98+
relation = relation.where(id: SolidQueue::ReadyExecution.select(:job_id))
8899
when 'in_progress'
89-
claimed_job_ids = SolidQueue::ClaimedExecution.pluck(:job_id)
90-
relation = relation.where(id: claimed_job_ids)
100+
relation = relation.where(id: SolidQueue::ClaimedExecution.select(:job_id))
91101
end
92102
end
93103

app/presenters/solid_queue_monitor/queues_presenter.rb

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
module SolidQueueMonitor
44
class QueuesPresenter < BasePresenter
5-
def initialize(records, paused_queues = [], sort: {})
6-
@records = records
5+
def initialize(records, paused_queues = [], sort: {}, queue_stats: {})
6+
@records = records
77
@paused_queues = paused_queues
8-
@sort = sort
8+
@sort = sort
9+
@queue_stats = queue_stats
910
end
1011

1112
def render
@@ -39,16 +40,16 @@ def generate_table
3940

4041
def generate_row(queue)
4142
queue_name = queue.queue_name || 'default'
42-
paused = @paused_queues.include?(queue_name)
43+
paused = @paused_queues.include?(queue_name)
4344

4445
<<-HTML
4546
<tr class="#{paused ? 'queue-paused' : ''}">
4647
<td>#{queue_link(queue_name)}</td>
4748
<td>#{status_badge(paused)}</td>
4849
<td>#{queue.job_count}</td>
49-
<td>#{ready_jobs_count(queue_name)}</td>
50-
<td>#{scheduled_jobs_count(queue_name)}</td>
51-
<td>#{failed_jobs_count(queue_name)}</td>
50+
<td>#{@queue_stats.dig(:ready, queue_name) || 0}</td>
51+
<td>#{@queue_stats.dig(:scheduled, queue_name) || 0}</td>
52+
<td>#{@queue_stats.dig(:failed, queue_name) || 0}</td>
5253
<td class="actions-cell">#{action_button(queue_name, paused)}</td>
5354
</tr>
5455
HTML
@@ -84,19 +85,5 @@ def action_button(queue_name, paused)
8485
HTML
8586
end
8687
end
87-
88-
def ready_jobs_count(queue_name)
89-
SolidQueue::ReadyExecution.where(queue_name: queue_name).count
90-
end
91-
92-
def scheduled_jobs_count(queue_name)
93-
SolidQueue::ScheduledExecution.where(queue_name: queue_name).count
94-
end
95-
96-
def failed_jobs_count(queue_name)
97-
SolidQueue::FailedExecution.joins(:job)
98-
.where(solid_queue_jobs: { queue_name: queue_name })
99-
.count
100-
end
10188
end
10289
end

app/presenters/solid_queue_monitor/stats_presenter.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,12 @@ def render
1111
<div class="stats-container">
1212
<h3>Queue Statistics</h3>
1313
<div class="stats">
14-
#{generate_stat_card('Total Jobs', @stats[:total_jobs])}
14+
#{generate_stat_card('Active Jobs', @stats[:active_jobs])}
1515
#{generate_stat_card('Ready', @stats[:ready])}
1616
#{generate_stat_card('In Progress', @stats[:in_progress])}
1717
#{generate_stat_card('Scheduled', @stats[:scheduled])}
1818
#{generate_stat_card('Recurring', @stats[:recurring])}
1919
#{generate_stat_card('Failed', @stats[:failed])}
20-
#{generate_stat_card('Completed', @stats[:completed])}
2120
</div>
2221
</div>
2322
HTML

0 commit comments

Comments
 (0)