diff --git a/CHANGELOG.md b/CHANGELOG.md index 764ec5a..26fdd04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Eliminate N+1 queries on the queues index — replaced per-queue `COUNT` loop with a single `GROUP BY queue_name` aggregation +- `CacheSizeStats#buckets` now runs a single `SUM(CASE WHEN ...)` aggregation instead of one `COUNT` query per size bucket; `#total` is derived from the already-computed bucket counts (no extra query) +- `JobsController` filter options (queue and priority dropdowns) now resolved with one `pluck` call instead of two separate queries + ### Added +- Covering indexes added to dummy app schema — `solid_queue_jobs (finished_at, created_at)` for the slow-job scan; `(queue_name, created_at)` on `solid_queue_scheduled_executions` and `solid_queue_blocked_executions` (both previously lacked a queue-name index) - Install generator — `rails generate solid_stack_web:install` creates `config/initializers/solid_stack_web.rb` with every config option documented inline and injects the mount line into `config/routes.rb` - `SolidStackWeb.mount_path` — returns the path at which the engine is mounted in the host app, derived automatically from routes; use `link_to "Dashboard", SolidStackWeb.mount_path` to link to the dashboard without hardcoding the path - Accessibility pass — skip-to-content link; ARIA labels on all navigation elements; `scope="col"` on every table header; visually-hidden "Actions" label on empty action-column headers; `aria-sort` on active sort columns in stats and cache entries; `aria_label: "Pagination"` on all pagination navs; `.sqw-sr-only` and `.sqw-skip-link` CSS utilities added to base stylesheet diff --git a/README.md b/README.md index 36881da..fa8e223 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ mount SolidStackWeb::Engine, at: "/solid_stack" The dashboard will be available at `/solid_stack` (or whatever path you choose). +### Install generator + +Run the install generator to create a documented initializer and wire up the mount point in one step: + +```bash +rails generate solid_stack_web:install +``` + +This creates `config/initializers/solid_stack_web.rb` with every configuration option commented inline, and injects `mount SolidStackWeb::Engine, at: "/solid_stack"` into `config/routes.rb`. + --- ## Metrics endpoint @@ -88,16 +98,6 @@ The `authenticate` block is evaluated in the context of each request's controlle link_to "Queue Dashboard", SolidStackWeb.mount_path ``` -### Install generator - -Run the install generator to create a documented initializer and wire up the mount point in one step: - -```bash -rails generate solid_stack_web:install -``` - -This creates `config/initializers/solid_stack_web.rb` with every configuration option commented inline, and injects `mount SolidStackWeb::Engine, at: "/solid_stack"` into `config/routes.rb`. - --- ## Solid Queue diff --git a/ROADMAP.md b/ROADMAP.md index 053da63..003cfc1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,6 @@ The path to v1.0.0 is staged: first achieve feature parity with `solid_queue_das > _Make it easy to adopt and easy to contribute to._ ### Remaining -- **Query optimisation** — eliminate N+1 queries across all list views; add covering indexes to the dummy app schema - **Error pages** — engine-scoped 404/500 views so errors stay within the dashboard chrome - **Changelog-driven upgrade notes** — `UPGRADING.md` for any breaking configuration changes diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index 510f25d..9216cbe 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -5,8 +5,11 @@ class JobsController < ApplicationController before_action :require_discardable, only: [:destroy] def index - @queue_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.queue_name").sort - @priority_options = Job::EXECUTION_MODELS[@status].joins(:job).distinct.pluck("solid_queue_jobs.priority").sort + pairs = Job::EXECUTION_MODELS[@status].joins(:job) + .distinct + .pluck("solid_queue_jobs.queue_name", "solid_queue_jobs.priority") + @queue_options = pairs.map(&:first).uniq.sort + @priority_options = pairs.map(&:last).uniq.sort respond_to do |format| format.html { @pagy, @executions = pagy(filtered_scope) } diff --git a/app/controllers/solid_stack_web/queues_controller.rb b/app/controllers/solid_stack_web/queues_controller.rb index c00a81d..21f8e2f 100644 --- a/app/controllers/solid_stack_web/queues_controller.rb +++ b/app/controllers/solid_stack_web/queues_controller.rb @@ -1,16 +1,11 @@ module SolidStackWeb class QueuesController < ApplicationController def index - queue_names = ::SolidQueue::ReadyExecution.distinct.pluck(:queue_name) - paused = ::SolidQueue::Pause.pluck(:queue_name).to_set + counts = ::SolidQueue::ReadyExecution.group(:queue_name).count + paused = ::SolidQueue::Pause.pluck(:queue_name).to_set - @queues = queue_names.sort.map do |name| - { - name: name, - size: ::SolidQueue::ReadyExecution.where(queue_name: name).count, - paused: paused.include?(name) - } - end + @queues = counts.map { |name, size| { name:, size:, paused: paused.include?(name) } } + .sort_by { |q| q[:name] } @sparklines = @queues.each_with_object({}) do |queue, h| h[queue[:name]] = QueueDepthSparkline.new(queue[:name]) diff --git a/app/models/solid_stack_web/cache_size_stats.rb b/app/models/solid_stack_web/cache_size_stats.rb index 16e8b8b..8e5d1f9 100644 --- a/app/models/solid_stack_web/cache_size_stats.rb +++ b/app/models/solid_stack_web/cache_size_stats.rb @@ -18,16 +18,20 @@ def top_entries end def buckets - @buckets ||= BUCKETS.map do |b| - scope = ::SolidCache::Entry.all - scope = scope.where("byte_size >= ?", b[:min]) if b[:min] > 0 - scope = scope.where("byte_size < ?", b[:max]) if b[:max] - { label: b[:label], count: scope.count } + @buckets ||= begin + row = ::SolidCache::Entry.pluck( + Arel.sql("COALESCE(SUM(CASE WHEN byte_size < 1024 THEN 1 ELSE 0 END), 0)"), + Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 1024 AND byte_size < 10240 THEN 1 ELSE 0 END), 0)"), + Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 10240 AND byte_size < 102400 THEN 1 ELSE 0 END), 0)"), + Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 102400 AND byte_size < 1048576 THEN 1 ELSE 0 END), 0)"), + Arel.sql("COALESCE(SUM(CASE WHEN byte_size >= 1048576 THEN 1 ELSE 0 END), 0)") + ).first || Array.new(5, 0) + BUCKETS.zip(row).map { |b, count| { label: b[:label], count: count.to_i } } end end def total - @total ||= ::SolidCache::Entry.count + @total ||= buckets.sum { |b| b[:count] } end end end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index a26929a..5249a19 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,6 +10,7 @@ t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + t.index ["queue_name", "created_at"], name: "index_solid_queue_blocked_executions_on_queue_name" end create_table "solid_queue_claimed_executions", force: :cascade do |t| @@ -41,6 +42,7 @@ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["finished_at", "created_at"], name: "index_solid_queue_jobs_on_finished_at_and_created_at" t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end @@ -107,6 +109,7 @@ t.datetime "scheduled_at", null: false t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["queue_name", "created_at"], name: "index_solid_queue_scheduled_executions_on_queue_name" t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end