roundhouse_ui 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11537f41a49c350dbd057961b82994fdc7524afdf3285961a28fa13701daaf57
4
- data.tar.gz: aedef27c3202b3d2eaf25a2b9fefb9290df222c66d1fde4ae8414152f3a477cf
3
+ metadata.gz: 6ee71201b4424c59d9450b7eea1fec5b7447e9e40ee419a4ebc03c39bf8189d6
4
+ data.tar.gz: 8a33d51ba920fd11b64ae9a28160ce40e87ff0b8c315a04136b64c7aa8427bb7
5
5
  SHA512:
6
- metadata.gz: f4b22e1a716bbbdfdbab62cc5b0dcb10094a3cefbefd7ad1f1a24367e1f383f4c4c1467d1aa840600f860b1bc65b433fbdbdc56da5e1f1c1545f1756a99ed83c
7
- data.tar.gz: cbe28c835c0e6413ee88658242e455e39a47cbd124a1e06ee0a9d40106ea9eb3ba49e2491f8dbd6f3a757ad71c0a24f5ead03b0b263b8a96237db3d2f8732b74
6
+ metadata.gz: 7494f64a2e7cea227f13ac6037e390775f068726f81e3962ecf0bf62a87d6dffaa7c613bef9c0d2b47bf4bdc5d410e6a2073e35c3a67723485af99fd30b43691
7
+ data.tar.gz: 2ff606ac7ca4797069b7cbb875313fd7988a962845f4c738765a8816a48280f5c889167a31d43452342389c3d122d798b15b782ac2219ba0cfb99d4125431bc0
data/README.md CHANGED
@@ -1,7 +1,6 @@
1
- <img width="3532" height="1956" alt="CleanShot 2026-06-27 at 21 07 23@2x" src="https://github.com/user-attachments/assets/7c27c997-d8d1-4e8e-b982-94c624ebfa2a" />
2
1
  # Roundhouse
3
-
4
- **A modern, real-time web UI for Sidekiq.**
2
+ <img width="4460" height="3152" alt="CleanShot 2026-07-01 at 09 42 17@2x" src="https://github.com/user-attachments/assets/3484709b-9c4f-449e-8776-53ad2de4781f" />
3
+ **A modern, real-time web UI for Sidekiq and Solid Queue.**
5
4
 
6
5
  [![CI](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml/badge.svg)](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml)
7
6
  [![Gem Version](https://img.shields.io/gem/v/roundhouse_ui)](https://rubygems.org/gems/roundhouse_ui)
@@ -9,32 +8,34 @@
9
8
  [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-D30001?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
10
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
11
10
 
12
- Roundhouse is a mountable Rails engine that replaces the stock Sidekiq Web UI with a
13
- control plane built for the way you actually operate background jobs: live stats,
14
- searchable sets, grouped errors, safe queue management, job inspection/editing, and
15
- Redis health all server-rendered with Turbo (no build step), and **no Sidekiq Pro
16
- required**.
11
+ Roundhouse is a mountable Rails engine a control plane built for the way you
12
+ actually operate background jobs: a high-signal dashboard, searchable sets, grouped
13
+ errors, smart bulk actions, safe queue management, and job inspection/editing. It
14
+ reads through a **backend port**, so the same UI drives **Sidekiq** or **Solid
15
+ Queue** (see [Backends](#backends)). All server-rendered with Turbo — **no build
16
+ step, no frontend dependency** — and **no Sidekiq Pro required**.
17
17
 
18
18
  > Gem name is `roundhouse_ui`; the brand and mount path are **Roundhouse**.
19
19
 
20
20
  ## Features
21
21
 
22
- - **Live dashboard** — throughput chart + stat cards refresh in place (no reload); polling pauses when the tab is hidden.
23
- - **Search** — find a job across the dead/retry/scheduled sets by class, JID, error, or argument value.
24
- - **Bulk actions** — select many dead jobs and retry/delete them at once (not 25-at-a-time).
25
- - **Grouped errors** — failures fingerprinted by `class + error`, so one bad deploy is a single issue with a count.
26
- - **Queue management** — pause/resume (OSS, see below), purge with an impact count, and **snapshot → restore**.
27
- - **Job inspection & editing** — view a job's full args (with redaction), error, and backtrace; edit & re-enqueue, or enqueue a brand-new job (opt-in).
28
- - **Workers** — process fleet with quiet/stop, threads, queues, and heartbeat.
29
- - **Redis pressure** — memory, ops/sec, and the eviction-policy check that flags silent job loss.
22
+ - **High-signal dashboard** — a composite health verdict (error rate + queue latency + utilization, with a "why"), *top failing job classes* and *problem queues* panels, and a live throughput chart with a configurable interval — all refreshing in place (polling pauses when the tab is hidden).
23
+ - **Grouped errors** — failures fingerprinted by `class + error`, so one bad deploy is a single issue with a count, not thousands of rows.
24
+ - **Smart bulk actions** — retry/delete every job matching a filter (not just the visible page), plus select-and-act on Dead.
25
+ - **Search** — across the dead/retry/scheduled sets by class, JID, error, or argument value.
26
+ - **Queue management** — pause/resume, purge with an impact count, and **snapshot → restore**.
27
+ - **Job inspection & editing** — full args (with redaction), error, and collapsible backtrace; edit & re-enqueue, or enqueue a new job (opt-in).
28
+ - **Per-class durations** (opt-in) the slowest job classes, which Sidekiq doesn't track.
30
29
  - **Audit log** — every state-changing action recorded and attributable.
31
- - **⌘K command palette**, light/dark themes, read-only mode, and a strict self-contained CSP.
30
+ - **⌘K command palette**, light/dark themes, compact/full-width toggle, read-only mode, and a strict self-contained CSP.
31
+
32
+ Sidekiq-specific extras: **Workers** (quiet/stop, threads, heartbeat), **Redis pressure** (eviction-policy check for silent job loss), and **Capsules**.
32
33
 
33
- Everything reads through Sidekiq's public API **no database**.
34
+ There's **no database of its own** — Roundhouse reads your job backend directly (Sidekiq via its API, Solid Queue via its tables).
34
35
 
35
36
  ## Requirements
36
37
 
37
- - Ruby >= 3.1 · Rails >= 7.0 · Sidekiq >= 7.0
38
+ - Ruby >= 3.1 · Rails >= 7.0 · Sidekiq >= 6.5 (or Solid Queue — see Backends)
38
39
 
39
40
  ## Installation
40
41
 
@@ -43,6 +44,28 @@ Everything reads through Sidekiq's public API — **no database**.
43
44
  gem "roundhouse_ui"
44
45
  ```
45
46
 
47
+ ## Backends
48
+
49
+ Roundhouse reads through a **backend port**, so the same UI can drive different
50
+ job systems. It defaults to **Sidekiq**; point it at **Solid Queue** in an
51
+ initializer:
52
+
53
+ ```ruby
54
+ # config/initializers/roundhouse.rb
55
+ RoundhouseUi.backend = RoundhouseUi::Backends::SolidQueue.new
56
+ ```
57
+
58
+ The UI adapts to each backend's capabilities — on Solid Queue, queue **pause is
59
+ native** (no fetcher, no warning), and the **Retries / Redis / Capsules / Workers**
60
+ sections hide (Solid Queue has no distinct retry set, isn't Redis-backed, and
61
+ processes are a follow-up). Dashboard, Queues, Scheduled, Dead, Busy, and the
62
+ grouped Errors view all work on both. See
63
+ [docs/adr/0001](docs/adr/0001-backend-port-multi-queue.md).
64
+
65
+ > Running **both** Sidekiq and Solid Queue in one app (e.g. mid-migration)? That's
66
+ > [#17](https://github.com/rjrobinson/roundhouse_ui/issues/17) — for now, one
67
+ > backend per Roundhouse instance.
68
+
46
69
  ## Mounting
47
70
 
48
71
  Roundhouse is auth-agnostic — wrap the mount in whatever your app already uses.
@@ -84,29 +107,52 @@ RoundhouseUi.configure do |c|
84
107
  # No-op unless the sidekiq-failures gem is loaded. Default: off.
85
108
  c.show_sidekiq_failures = true
86
109
 
87
- # Set false to hide queue pause/resume controls and the "not enforced" warning,
88
- # e.g. when you run reliable fetch (super_fetch) instead of RoundhouseUi::Fetch.
89
- # Default: true.
110
+ # Set false to hide queue pause/resume controls entirely. Rarely needed on
111
+ # Sidekiq Pro and Solid Queue pause is enforced natively, and on OSS Sidekiq
112
+ # installing RoundhouseUi::Fetch enforces it. Default: true.
90
113
  # c.pause_enabled = false
91
114
 
92
115
  # Seconds between dashboard stat polls (default 5). Raise it if polling shows
93
116
  # up in your traces — each poll re-runs the host's auth/routing on the mount.
94
117
  # c.poll_interval = 10
95
118
 
96
- # Show the "slowest job classes" table on the Metrics page. Requires the
97
- # DurationCollector middleware (see below). Default: false.
119
+ # Show the "slowest job classes" table on the Metrics page. The flag alone shows
120
+ # nothing — it also needs the DurationCollector middleware (see below).
121
+ # Default: false.
98
122
  # c.collect_durations = true
99
123
  end
100
124
  ```
101
125
 
102
- Every option is independent and has a safe default — set only what you need.
103
- `read_only`, `allow_job_editing`, and `show_sidekiq_failures` default to `false`;
104
- `redact_args` to `[]`; `observability` to a no-op adapter; `snapshot_store` to Redis.
126
+ Every option is independent and has a safe default — **set only what you need**. Nothing
127
+ here is required to mount Roundhouse.
128
+
129
+ ### When to turn each one on
130
+
131
+ | Option | Default | Turn it on when | Leave it alone when |
132
+ |---|---|---|---|
133
+ | `read_only` | `false` | **Production, almost always.** Blocks purge/retry/delete/edit *server-side*, not just in the UI — so it holds even if someone hand-crafts a request. The usual shape is `!Rails.env.development?`. | You need operators to actually fix things from the UI, and you trust everyone behind the mount. |
134
+ | `redact_args` | `[]` | **Any app whose job args carry secrets or PII** — args render in full on the job page. Matches keys case-insensitively as substrings, and walks nested hashes/arrays. | Args are all IDs and enum values. |
135
+ | `actor_resolver` | `"anonymous"` | You want the audit log to name *who* did something. One line: `->(c) { c.current_user&.email }`. | Single-operator app, or you already audit at another layer. |
136
+ | `allow_job_editing` | `false` | Development and debugging. **Sharp tool** — a bad edit creates an unrunnable job, and it lets the UI enqueue arbitrary classes. | Production, unless you specifically want that power and have `read_only` off anyway. |
137
+ | `observability` | no-op | You run an APM and want per-job deep links out to it. Ships a Datadog adapter; duck-type `job_url`/`queue_url`/`error_url` for anything else. | No APM, or you'd rather not add links that only some people can open. |
138
+ | `snapshot_store` | Redis | Your snapshots are large or need to outlive Redis (S3/disk). Duck-type `write`/`read`/`delete`/`ids`. | Redis is fine — which it usually is for occasional queue snapshots. |
139
+ | `show_sidekiq_failures` | `false` | You use the `sidekiq-failures` gem **and** run jobs with `retry: false` — those never enter Sidekiq's retry/dead sets, so this is the only way to see them. | You don't have the gem (it's a no-op then anyway). |
140
+ | `poll_interval` | `5` | **Raise it** if dashboard polling shows up in your traces — every poll re-runs your app's auth and routing on the mount, so a busy console adds real load. Lower it only for a livelier demo. | Default is fine for most apps. |
141
+ | `collect_durations` | `false` | You want "slowest job classes" on Metrics, which Sidekiq doesn't track. **Also requires installing the `DurationCollector` middleware** — the flag alone shows nothing. Costs one pipelined Redis round-trip per job. | You already get per-job timing from your APM. |
142
+ | `pause_enabled` | `true` | Leave it on. | **Rarely set this to `false`.** Pause is enforced natively on Sidekiq Pro and Solid Queue, and on OSS Sidekiq by installing `RoundhouseUi::Fetch` — so turning it off usually just hides a working feature. Only useful if you want the controls gone entirely. |
143
+
144
+ Two that pair with a middleware rather than working alone: `collect_durations`
145
+ (`DurationCollector`) and job cancellation (`CancelMiddleware`) — see
146
+ [Cancelling jobs](#cancelling-jobs) and [Slowest job classes](#slowest-job-classes).
105
147
 
106
148
  ## Pausing queues
107
149
 
108
- Pause/resume is pure OSS. To make a pause actually stop a queue from being worked,
109
- install Roundhouse's fetch strategy in your Sidekiq **server** config:
150
+ > Pause is **native** enforced with nothing to install and no warning on both
151
+ > **Solid Queue** and **Sidekiq Pro/Enterprise** (see below). The fetch strategy
152
+ > below is only needed on **OSS Sidekiq**.
153
+
154
+ On OSS Sidekiq, pause/resume is pure OSS. To make a pause actually stop a queue from
155
+ being worked, install Roundhouse's fetch strategy in your Sidekiq **server** config:
110
156
 
111
157
  ```ruby
112
158
  # config/initializers/sidekiq.rb
@@ -120,11 +166,26 @@ all of Sidekiq's weighting/ordering. Until it's installed, the Queues page recor
120
166
  but **warns that they aren't enforced** (worker and web are separate processes, so
121
167
  Roundhouse detects whether a fetcher has reported in).
122
168
 
123
- > ⚠️ **Sidekiq Pro/Enterprise users:** super_fetch / reliable fetch sets its own
124
- > `fetch_class`, and Sidekiq allows only one. Installing `RoundhouseUi::Fetch` would
125
- > **replace reliable fetch and lose its crash-recovery guarantees** don't. On those
126
- > tiers, leave the fetch strategy out and set `RoundhouseUi.pause_enabled = false` to
127
- > hide the pause controls and the (otherwise permanent) "not enforced" warning.
169
+ ### Sidekiq Pro / Enterprise nothing to install
170
+
171
+ Pro ships its own enforced pause, and Roundhouse uses it automatically. Pro reopens
172
+ `Sidekiq::Queue` with `pause!`/`unpause!` and *prepends* pause support onto
173
+ `Sidekiq::BasicFetch` (`super_fetch` honors it too), so **any Pro worker enforces
174
+ pauses** whether or not a fetch strategy is configured.
175
+
176
+ When Roundhouse detects Pro it delegates pause/resume to `Sidekiq::Queue#pause!`,
177
+ reads paused state from Pro's registry, advertises `native_pause`, and drops the
178
+ "not enforced" warning. So on Pro:
179
+
180
+ - **Don't** install `RoundhouseUi::Fetch` — it isn't needed, and on `super_fetch`
181
+ installs it would displace reliable fetch and lose its crash-recovery guarantees.
182
+ - **Don't** set `pause_enabled = false` — pause genuinely works; disabling it only
183
+ hides a feature you already have.
184
+
185
+ Roundhouse always goes through `Sidekiq::Queue#pause!` rather than writing Pro's
186
+ Redis key directly: Pro's fetchers read that set once at startup and afterwards
187
+ only update on the `pro:config` pubsub message `pause!` publishes, so a raw write
188
+ would leave running workers pulling the queue until they restarted.
128
189
 
129
190
  ## Surfacing sidekiq-failures
130
191
 
@@ -158,6 +219,15 @@ The **Busy** page's Cancel button flags a job's JID. A queued/scheduled/retrying
158
219
  is then skipped when it would next run; a *currently running* job stops only if it
159
220
  checks in — e.g. a long loop can `break if RoundhouseUi.cancelled?(jid)`.
160
221
 
222
+ **Timing and cost.** The middleware is close to free when nothing is cancelled: rather
223
+ than checking each job's JID against Redis, it asks "is *anything* cancelled?" from a
224
+ process-local gate refreshed at most every 2s, and only does the exact per-job lookup
225
+ while cancellations are pending. The tradeoff is that a cancellation takes **up to ~2s
226
+ to reach a worker process** — the UI and your workers are separate processes, so expect
227
+ a brief lag after clicking Cancel. Jobs already in flight are unaffected either way
228
+ (cancellation is cooperative), and `RoundhouseUi.cancelled?(jid)` — what a long-running
229
+ job polls — is never gated, so it always reads current state.
230
+
161
231
  ## Slowest job classes
162
232
 
163
233
  Sidekiq doesn't track per-class durations, so Roundhouse can record them itself.
@@ -171,8 +241,8 @@ Sidekiq.configure_server do |config|
171
241
  end
172
242
  ```
173
243
 
174
- It's two cheap Redis writes per job (a counter + a summed-ms float) into a single
175
- hash, and a job failure never propagates from the collector.
244
+ It's two cheap Redis writes per job (a counter + a summed-ms float) into a single hash,
245
+ pipelined into **one round-trip**, and a job failure never propagates from the collector.
176
246
 
177
247
  ## Bulk actions on a filter
178
248
 
@@ -220,8 +290,10 @@ Redis and run `bin/rails server` to click around.
220
290
 
221
291
  ## Roadmap
222
292
 
293
+ - Solid Queue: Workers view + enqueue, and the multi-DB (separate queue database) case.
294
+ - Watch Sidekiq **and** Solid Queue from one install ([#17](https://github.com/rjrobinson/roundhouse_ui/issues/17)).
223
295
  - Multi-Redis / multi-cluster view (one pane across shards).
224
- - Capsules and cron/periodic views.
296
+ - Cron/periodic (recurring) views.
225
297
 
226
298
  ## Contributing
227
299
 
@@ -36,6 +36,12 @@ module RoundhouseUi
36
36
 
37
37
  private
38
38
 
39
+ # The queue backend (Sidekiq by default). Controllers read through this
40
+ # rather than naming Sidekiq directly. See ADR 0001.
41
+ def backend
42
+ RoundhouseUi.backend
43
+ end
44
+
39
45
  def record_audit_event
40
46
  target = params[:name] || params[:jid] || params[:id] || params[:job_class] ||
41
47
  (params[:jids].presence && "#{Array(params[:jids]).size} jobs") || params[:op]
@@ -9,7 +9,7 @@ module RoundhouseUi
9
9
 
10
10
  def index
11
11
  @threshold = LONG_RUNNING
12
- @work = Sidekiq::WorkSet.new.map { |process_id, tid, work| normalize(process_id, tid, work) }
12
+ @work = backend.busy
13
13
  end
14
14
 
15
15
  def cancel
@@ -19,18 +19,6 @@ module RoundhouseUi
19
19
 
20
20
  private
21
21
 
22
- # Sidekiq 7+ yields a Sidekiq::Work (queue/run_at/job methods); Sidekiq 6.x
23
- # yields a plain Hash (string keys, an epoch run_at, a JSON payload). Normalize
24
- # both to the same shape the view expects (run_at as a Time, job as a JobRecord).
25
- def normalize(process_id, tid, work)
26
- if work.respond_to?(:queue)
27
- { process: process_id, tid: tid, queue: work.queue, run_at: work.run_at, job: work.job }
28
- else
29
- { process: process_id, tid: tid, queue: work["queue"],
30
- run_at: Time.at(work["run_at"]), job: Sidekiq::JobRecord.new(work["payload"]) }
31
- end
32
- end
33
-
34
22
  def require_writable!
35
23
  return unless RoundhouseUi.read_only
36
24
  redirect_to busy_path, alert: "Roundhouse is in read-only mode — cancellation is disabled."
@@ -12,7 +12,7 @@ module RoundhouseUi
12
12
  def aggregate
13
13
  capsules = Hash.new { |h, k| h[k] = { name: k, concurrency: 0, processes: 0, queues: {} } }
14
14
 
15
- Sidekiq::ProcessSet.new.each do |process|
15
+ backend.process_set.each do |process|
16
16
  # `capsules` is a Sidekiq 8.0.8+ Process attribute; absent on 6.x/early 7,
17
17
  # where calling it would raise NoMethodError. Skip → view shows empty state.
18
18
  next unless process.respond_to?(:capsules)
@@ -3,8 +3,8 @@ module RoundhouseUi
3
3
  # Everything here comes out of Redis via Sidekiq::Stats / Sidekiq::Queue.
4
4
  class DashboardController < ApplicationController
5
5
  def show
6
- @stats = Sidekiq::Stats.new
7
- @queues = Sidekiq::Queue.all
6
+ @stats = backend.stats
7
+ @queues = backend.queues
8
8
  @metrics = Metrics.new(stats: @stats)
9
9
  @health = Health.new(stats: @stats, queues: @queues, metrics: @metrics)
10
10
  # Highest-signal slices for the overview, from data we already read.
@@ -15,7 +15,7 @@ module RoundhouseUi
15
15
  # Polled by the dashboard for live counts (same approach Sidekiq Web uses —
16
16
  # cheap JSON, no WebSocket/build step required).
17
17
  def stats
18
- s = Sidekiq::Stats.new
18
+ s = backend.stats
19
19
  render json: {
20
20
  processed: s.processed,
21
21
  failed: s.failed,
@@ -24,7 +24,7 @@ module RoundhouseUi
24
24
  scheduled: s.scheduled_size,
25
25
  retries: s.retry_size,
26
26
  dead: s.dead_size,
27
- queues: Sidekiq::Queue.all.size
27
+ queues: backend.queues.size
28
28
  }
29
29
  end
30
30
  end
@@ -7,25 +7,25 @@ module RoundhouseUi
7
7
  def index
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
- @total = Sidekiq::DeadSet.new.size
11
- @jobs, @has_next = browse(Sidekiq::DeadSet.new, @query, @page)
10
+ @total = backend.dead_set.size
11
+ @jobs, @has_next = browse(backend.dead_set, @query, @page)
12
12
  end
13
13
 
14
14
  def requeue
15
- entry = Sidekiq::DeadSet.new.find_job(params[:jid])
15
+ entry = backend.dead_set.find_job(params[:jid])
16
16
  entry&.retry
17
17
  redirect_to dead_set_path, notice: entry ? "Re-enqueued #{params[:jid]}." : "Job is no longer in the dead set."
18
18
  end
19
19
 
20
20
  def destroy
21
- entry = Sidekiq::DeadSet.new.find_job(params[:jid])
21
+ entry = backend.dead_set.find_job(params[:jid])
22
22
  entry&.delete
23
23
  redirect_to dead_set_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer in the dead set."
24
24
  end
25
25
 
26
26
  # Act on many at once: retry or delete every selected job in one request.
27
27
  def bulk
28
- set = Sidekiq::DeadSet.new
28
+ set = backend.dead_set
29
29
  count = 0
30
30
  Array(params[:jids]).each do |jid|
31
31
  entry = set.find_job(jid) or next
@@ -40,7 +40,7 @@ module RoundhouseUi
40
40
  # selected/visible ones), capped for safety. Only offered when a filter is
41
41
  # active, so it can't become "retry the entire dead set" by accident.
42
42
  def bulk_all
43
- count, capped = bulk_apply(Sidekiq::DeadSet.new, params[:q].to_s.strip, params[:op])
43
+ count, capped = bulk_apply(backend.dead_set, params[:q].to_s.strip, params[:op])
44
44
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
45
45
  note = "#{verb} #{count} matching job(s)."
46
46
  note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
@@ -5,11 +5,6 @@ module RoundhouseUi
5
5
  # Sidekiq has no in-place edit: a job in a set is keyed by its payload, so an
6
6
  # "edit" is delete-the-old + push-the-modified.
7
7
  class JobsController < ApplicationController
8
- SET_BUILDERS = {
9
- "dead" => -> { Sidekiq::DeadSet.new },
10
- "retry" => -> { Sidekiq::RetrySet.new },
11
- "scheduled" => -> { Sidekiq::ScheduledSet.new }
12
- }.freeze
13
8
  REDIRECTS = { "dead" => :dead_set_path, "retry" => :retries_path, "scheduled" => :scheduled_path }.freeze
14
9
 
15
10
  before_action :require_editing_enabled!, except: :show
@@ -32,7 +27,7 @@ module RoundhouseUi
32
27
  raise ArgumentError, "Job class is required" if klass.empty?
33
28
 
34
29
  queue = params[:queue].presence || "default"
35
- Sidekiq::Client.push("class" => klass, "queue" => queue, "args" => args)
30
+ backend.push("class" => klass, "queue" => queue, "args" => args)
36
31
  redirect_to queues_path, notice: "Enqueued #{klass} → #{queue}."
37
32
  rescue ArgumentError => e
38
33
  render_form_error(:new, jobs_path, e)
@@ -56,7 +51,7 @@ module RoundhouseUi
56
51
  queue = params[:queue].presence || entry.queue
57
52
 
58
53
  entry.delete
59
- Sidekiq::Client.push("class" => klass, "queue" => queue, "args" => args)
54
+ backend.push("class" => klass, "queue" => queue, "args" => args)
60
55
  redirect_to send(REDIRECTS[params[:set]]), notice: "Edited & re-enqueued #{klass} → #{queue}."
61
56
  rescue ArgumentError => e
62
57
  @action_path = job_path(set: params[:set], jid: params[:jid])
@@ -66,8 +61,8 @@ module RoundhouseUi
66
61
  private
67
62
 
68
63
  def find_entry
69
- builder = SET_BUILDERS[params[:set]] or return nil
70
- builder.call.find_job(params[:jid])
64
+ set = backend.set(params[:set]) or return nil
65
+ set.find_job(params[:jid])
71
66
  end
72
67
 
73
68
  def parse_args!(raw)
@@ -3,24 +3,27 @@ module RoundhouseUi
3
3
  before_action :require_writable!, only: %i[purge pause resume]
4
4
 
5
5
  def index
6
- @queues = Sidekiq::Queue.all
7
- @paused = RoundhouseUi::Pause.paused_set
8
- @fetch_installed = RoundhouseUi::Pause.fetch_installed?
6
+ @queues = backend.queues
7
+ @paused = backend.paused_queues
8
+ # Native-pause backends (Solid Queue) enforce pauses without a fetcher, so
9
+ # they never trigger the "not enforced" warning.
10
+ @fetch_installed = backend.supports?(:native_pause) ||
11
+ (backend.respond_to?(:fetch_installed?) && backend.fetch_installed?)
9
12
  end
10
13
 
11
14
  # Real, OSS-supported destructive action: empties the queue in Redis.
12
15
  def purge
13
- Sidekiq::Queue.new(params[:name]).clear
16
+ backend.queue(params[:name]).clear
14
17
  redirect_to queues_path, notice: "Purged queue “#{params[:name]}”."
15
18
  end
16
19
 
17
20
  def pause
18
- RoundhouseUi::Pause.pause!(params[:name])
21
+ backend.pause(params[:name])
19
22
  redirect_to queues_path, notice: "Paused “#{params[:name]}”."
20
23
  end
21
24
 
22
25
  def resume
23
- RoundhouseUi::Pause.unpause!(params[:name])
26
+ backend.resume(params[:name])
24
27
  redirect_to queues_path, notice: "Resumed “#{params[:name]}”."
25
28
  end
26
29
 
@@ -7,19 +7,19 @@ module RoundhouseUi
7
7
  def index
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
- @total = Sidekiq::RetrySet.new.size
11
- @jobs, @has_next = browse(Sidekiq::RetrySet.new, @query, @page)
10
+ @total = backend.retry_set.size
11
+ @jobs, @has_next = browse(backend.retry_set, @query, @page)
12
12
  end
13
13
 
14
14
  # Retry now — moves the job back to its queue immediately.
15
15
  def requeue
16
- entry = Sidekiq::RetrySet.new.find_job(params[:jid])
16
+ entry = backend.retry_set.find_job(params[:jid])
17
17
  entry&.retry
18
18
  redirect_to retries_path, notice: entry ? "Re-enqueued #{params[:jid]}." : "Job is no longer in the retry set."
19
19
  end
20
20
 
21
21
  def destroy
22
- entry = Sidekiq::RetrySet.new.find_job(params[:jid])
22
+ entry = backend.retry_set.find_job(params[:jid])
23
23
  entry&.delete
24
24
  redirect_to retries_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer in the retry set."
25
25
  end
@@ -27,7 +27,7 @@ module RoundhouseUi
27
27
  # Smart bulk: retry/delete EVERY job matching the current filter, capped for
28
28
  # safety. Offered only when a filter is active.
29
29
  def bulk_all
30
- count, capped = bulk_apply(Sidekiq::RetrySet.new, params[:q].to_s.strip, params[:op])
30
+ count, capped = bulk_apply(backend.retry_set, params[:q].to_s.strip, params[:op])
31
31
  verb = params[:op] == "delete" ? "Deleted" : "Re-enqueued"
32
32
  note = "#{verb} #{count} matching job(s)."
33
33
  note += " Stopped at the #{JobSetBrowsing::BULK_CAP} cap — run again for more." if capped
@@ -7,19 +7,19 @@ module RoundhouseUi
7
7
  def index
8
8
  @query = params[:q].to_s.strip
9
9
  @page = [ params[:page].to_i, 1 ].max
10
- @total = Sidekiq::ScheduledSet.new.size
11
- @jobs, @has_next = browse(Sidekiq::ScheduledSet.new, @query, @page)
10
+ @total = backend.scheduled_set.size
11
+ @jobs, @has_next = browse(backend.scheduled_set, @query, @page)
12
12
  end
13
13
 
14
14
  # Enqueue now — pulls the job out of the schedule and onto its queue.
15
15
  def enqueue
16
- entry = Sidekiq::ScheduledSet.new.find_job(params[:jid])
16
+ entry = backend.scheduled_set.find_job(params[:jid])
17
17
  entry&.add_to_queue
18
18
  redirect_to scheduled_path, notice: entry ? "Enqueued #{params[:jid]} now." : "Job is no longer scheduled."
19
19
  end
20
20
 
21
21
  def destroy
22
- entry = Sidekiq::ScheduledSet.new.find_job(params[:jid])
22
+ entry = backend.scheduled_set.find_job(params[:jid])
23
23
  entry&.delete
24
24
  redirect_to scheduled_path, notice: entry ? "Deleted #{params[:jid]}." : "Job is no longer scheduled."
25
25
  end
@@ -5,7 +5,7 @@ module RoundhouseUi
5
5
  before_action :require_writable!, only: %i[quiet stop]
6
6
 
7
7
  def index
8
- @processes = Sidekiq::ProcessSet.new.to_a
8
+ @processes = backend.process_set.to_a
9
9
  @fetch_active = RoundhouseUi::Pause.fetch_installed?
10
10
  end
11
11
 
@@ -22,7 +22,7 @@ module RoundhouseUi
22
22
  private
23
23
 
24
24
  def find_process(identity)
25
- Sidekiq::ProcessSet.new.find { |process| process.identity == identity }
25
+ backend.process_set.find { |process| process.identity == identity }
26
26
  end
27
27
 
28
28
  def require_writable!
@@ -357,14 +357,14 @@
357
357
  { label: "Dashboard", path: "<%= root_path %>", icon: "▦" },
358
358
  { label: "Metrics", path: "<%= metrics_path %>", icon: "▤" },
359
359
  { label: "Busy", path: "<%= busy_path %>", icon: "◐" },
360
- { label: "Workers", path: "<%= workers_path %>", icon: "◷" },
360
+ <% if RoundhouseUi.backend.supports?(:workers) %>{ label: "Workers", path: "<%= workers_path %>", icon: "◷" },<% end %>
361
361
  { label: "Queues", path: "<%= queues_path %>", icon: "≡" },
362
362
  { label: "Scheduled", path: "<%= scheduled_path %>", icon: "◔" },
363
- { label: "Retries", path: "<%= retries_path %>", icon: "↻" },
363
+ <% if RoundhouseUi.backend.supports?(:retries) %>{ label: "Retries", path: "<%= retries_path %>", icon: "↻" },<% end %>
364
364
  { label: "Errors", path: "<%= errors_path %>", icon: "⚠" },
365
365
  { label: "Dead", path: "<%= dead_set_path %>", icon: "✕" },
366
- <% if defined?(Sidekiq::Capsule) %>{ label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },<% end %>
367
- { label: "Redis", path: "<%= redis_info_path %>", icon: "◈" },
366
+ <% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>{ label: "Capsules", path: "<%= capsules_path %>", icon: "⊞" },<% end %>
367
+ <% if RoundhouseUi.backend.supports?(:redis) %>{ label: "Redis", path: "<%= redis_info_path %>", icon: "◈" },<% end %>
368
368
  { label: "Snapshots", path: "<%= snapshots_path %>", icon: "⛁" },
369
369
  { label: "Audit log", path: "<%= audit_log_path %>", icon: "☷" },
370
370
  <% if RoundhouseUi.allow_job_editing %>{ label: "Enqueue job", path: "<%= new_job_path %>", icon: "+" },<% end %>
@@ -448,13 +448,17 @@
448
448
  <%= nav_link "Dashboard", root_path, icon: "▦" %>
449
449
  <%= nav_link "Metrics", metrics_path, icon: "▤" %>
450
450
  <%= nav_link "Busy", busy_path, icon: "◐", badge: "busy" %>
451
- <%= nav_link "Workers", workers_path, icon: "◷" %>
451
+ <% if RoundhouseUi.backend.supports?(:workers) %>
452
+ <%= nav_link "Workers", workers_path, icon: "◷" %>
453
+ <% end %>
452
454
  </nav>
453
455
  <nav class="rh-grp">
454
456
  <div class="rh-grp-label">Work</div>
455
457
  <%= nav_link "Queues", queues_path, icon: "≡", badge: "queues" %>
456
458
  <%= nav_link "Scheduled", scheduled_path, icon: "◔", badge: "scheduled" %>
457
- <%= nav_link "Retries", retries_path, icon: "↻", badge: "retries", badge_class: "warn" %>
459
+ <% if RoundhouseUi.backend.supports?(:retries) %>
460
+ <%= nav_link "Retries", retries_path, icon: "↻", badge: "retries", badge_class: "warn" %>
461
+ <% end %>
458
462
  <%= nav_link "Errors", errors_path, icon: "⚠" %>
459
463
  <%= nav_link "Dead", dead_set_path, icon: "✕", badge: "dead", badge_class: "crit" %>
460
464
  <% if RoundhouseUi.allow_job_editing %>
@@ -463,11 +467,13 @@
463
467
  </nav>
464
468
  <nav class="rh-grp">
465
469
  <div class="rh-grp-label">Infrastructure</div>
466
- <%# Capsules are a Sidekiq 7+ concept — hide the nav entirely on 6.x. %>
467
- <% if defined?(Sidekiq::Capsule) %>
470
+ <%# Capsules are a Sidekiq 7+ concept — hide on 6.x and on non-Sidekiq backends. %>
471
+ <% if defined?(Sidekiq::Capsule) && RoundhouseUi.backend.supports?(:capsules) %>
468
472
  <%= nav_link "Capsules", capsules_path, icon: "⊞" %>
469
473
  <% end %>
470
- <%= nav_link "Redis", redis_info_path, icon: "◈" %>
474
+ <% if RoundhouseUi.backend.supports?(:redis) %>
475
+ <%= nav_link "Redis", redis_info_path, icon: "◈" %>
476
+ <% end %>
471
477
  <%= nav_link "Snapshots", snapshots_path, icon: "⛁" %>
472
478
  <%= nav_link "Audit log", audit_log_path, icon: "☷" %>
473
479
  </nav>
@@ -0,0 +1,70 @@
1
+ module RoundhouseUi
2
+ module Backends
3
+ # The Sidekiq implementation of the Roundhouse backend port. It is a thin
4
+ # seam over Sidekiq's own API objects — controllers ask the backend for a
5
+ # "set" or "stats" instead of naming `Sidekiq::*` directly, so a second
6
+ # backend (Solid Queue) can supply duck-typed equivalents without touching
7
+ # controllers or views.
8
+ #
9
+ # Deliberately thin for now: each method returns the same Sidekiq object the
10
+ # controllers used before, so this refactor changes no behavior. The contract
11
+ # (what a "set" / "entry" must respond to) will be firmed up as the Solid
12
+ # Queue backend is built against it.
13
+ class Sidekiq
14
+ def name = "Sidekiq"
15
+
16
+ # Capabilities let the UI hide what a backend can't do. Sidekiq supports
17
+ # all the sets/views. On OSS Sidekiq pause is NOT native (it needs our
18
+ # fetcher), so :native_pause is withheld and the "not enforced" warning
19
+ # applies — but Sidekiq Pro ships its own enforced pause, so there it is
20
+ # advertised and the warning drops away.
21
+ CAPABILITIES = %i[retries dead scheduled busy workers redis capsules].freeze
22
+
23
+ def supports?(capability)
24
+ return RoundhouseUi::Pause.native? if capability == :native_pause
25
+
26
+ CAPABILITIES.include?(capability)
27
+ end
28
+
29
+ def stats = ::Sidekiq::Stats.new
30
+ def queues = ::Sidekiq::Queue.all
31
+ def queue(name) = ::Sidekiq::Queue.new(name)
32
+ def retry_set = ::Sidekiq::RetrySet.new
33
+ def dead_set = ::Sidekiq::DeadSet.new
34
+ def scheduled_set = ::Sidekiq::ScheduledSet.new
35
+ def work_set = ::Sidekiq::WorkSet.new
36
+ def process_set = ::Sidekiq::ProcessSet.new
37
+
38
+ # The job set a given UI section maps to (used by the job-detail page).
39
+ # Lazy — only builds the requested set.
40
+ def set(kind)
41
+ case kind.to_s
42
+ when "dead" then dead_set
43
+ when "retry" then retry_set
44
+ when "scheduled" then scheduled_set
45
+ end
46
+ end
47
+
48
+ def push(payload) = ::Sidekiq::Client.push(payload)
49
+
50
+ # Currently-executing jobs, normalized to the Busy view's shape. (This
51
+ # normalization used to live in BusyController; it belongs to the backend.)
52
+ def busy
53
+ ::Sidekiq::WorkSet.new.map do |process_id, tid, work|
54
+ if work.respond_to?(:queue) # Sidekiq 7+: Sidekiq::Work struct
55
+ { process: process_id, tid: tid, queue: work.queue, run_at: work.run_at, job: work.job }
56
+ else # Sidekiq 6.x: a plain Hash
57
+ { process: process_id, tid: tid, queue: work["queue"],
58
+ run_at: Time.at(work["run_at"]), job: ::Sidekiq::JobRecord.new(work["payload"]) }
59
+ end
60
+ end
61
+ end
62
+
63
+ # --- pause (via the opt-in Roundhouse fetcher; not native) ---
64
+ def paused_queues = RoundhouseUi::Pause.paused_set
65
+ def pause(name) = RoundhouseUi::Pause.pause!(name)
66
+ def resume(name) = RoundhouseUi::Pause.unpause!(name)
67
+ def fetch_installed? = RoundhouseUi::Pause.fetch_installed?
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,133 @@
1
+ require "json"
2
+
3
+ module RoundhouseUi
4
+ module Backends
5
+ # Solid Queue implementation of the backend port. Reads Solid Queue's
6
+ # ActiveRecord tables and normalizes them to the same shape the Sidekiq
7
+ # backend exposes, so controllers/views don't change.
8
+ #
9
+ # Solid Queue has no "retry set" (retries are just re-scheduled executions),
10
+ # no Redis, and no capsules — the UI hides those via #supports?. Queue pause
11
+ # is native, so there's no fetcher to warn about.
12
+ class SolidQueue
13
+ def name = "Solid Queue"
14
+
15
+ # No retries/redis/capsules/workers (deferred); pause is native (no fetcher
16
+ # warning). dead/scheduled/busy are supported.
17
+ CAPABILITIES = %i[dead scheduled busy native_pause].freeze
18
+ def supports?(capability) = CAPABILITIES.include?(capability)
19
+
20
+ def stats
21
+ Stats.new(
22
+ processed: sq(:Job).where.not(finished_at: nil).count,
23
+ failed: sq(:FailedExecution).count,
24
+ enqueued: sq(:ReadyExecution).count,
25
+ scheduled: sq(:ScheduledExecution).count,
26
+ busy: sq(:ClaimedExecution).count,
27
+ dead: sq(:FailedExecution).count,
28
+ retries: 0
29
+ )
30
+ end
31
+
32
+ def queues = ::SolidQueue::Queue.all
33
+ def queue(name) = ::SolidQueue::Queue.new(name)
34
+
35
+ # No retry set in Solid Queue — surfaced via supports?(:retries) => false.
36
+ def retry_set = EMPTY_SET
37
+
38
+ # Workers view is deferred (supports?(:workers) == false), and Solid Queue
39
+ # processes don't report thread concurrency the way Metrics expects, so the
40
+ # utilization signal reads as "no workers reporting" rather than crashing.
41
+ def process_set = []
42
+
43
+ def dead_set = JobSet.new(sq(:FailedExecution).includes(:job).order(created_at: :desc))
44
+ def scheduled_set = JobSet.new(sq(:ScheduledExecution).includes(:job).order(:scheduled_at))
45
+
46
+ # Currently-executing jobs, normalized to the Busy view's shape.
47
+ def busy
48
+ sq(:ClaimedExecution).includes(:job, :process).map do |claim|
49
+ e = Entry.new(claim)
50
+ { process: claim.process&.name, tid: claim.id, queue: e.queue, run_at: claim.created_at, job: e }
51
+ end
52
+ end
53
+
54
+ def set(kind)
55
+ case kind.to_s
56
+ when "dead" then dead_set
57
+ when "scheduled" then scheduled_set
58
+ end
59
+ end
60
+
61
+ # --- pause (native) ---
62
+ def paused_queues = ::SolidQueue::Pause.pluck(:queue_name)
63
+ def pause(name) = ::SolidQueue::Queue.new(name).pause
64
+ def resume(name) = ::SolidQueue::Queue.new(name).resume
65
+
66
+ # ActiveJob enqueues via perform_later; a raw push has no direct analogue.
67
+ def push(_payload) = raise NotImplementedError, "enqueue is Sidekiq-only for now"
68
+
69
+ private
70
+
71
+ def sq(model) = ::SolidQueue.const_get(model)
72
+
73
+ # A collection matching the JobSet contract (each / find_job / size).
74
+ class JobSet
75
+ include Enumerable
76
+ def initialize(relation) = @relation = relation
77
+ def each(&blk) = @relation.each { |exec| blk.call(Entry.new(exec)) }
78
+ def size = @relation.count
79
+ def find_job(jid) = (exec = @relation.find_by(job_id: jid)) && Entry.new(exec)
80
+ end
81
+
82
+ EMPTY_SET = JobSet.new([]).freeze
83
+
84
+ # Wraps a Solid Queue execution + its job in the SortedEntry-like interface
85
+ # the views expect (klass / jid / args / item / at / queue / retry / delete).
86
+ class Entry
87
+ def initialize(execution)
88
+ @execution = execution
89
+ @job = execution.job
90
+ end
91
+
92
+ def jid = @execution.job_id.to_s
93
+ def klass = @job&.class_name
94
+ def queue = @job&.queue_name
95
+ def args = active_job_args
96
+ def at = @execution.try(:scheduled_at) || @execution.try(:created_at)
97
+
98
+ def item
99
+ err = (@execution.respond_to?(:error) && @execution.error) || {}
100
+ {
101
+ "class" => klass, "args" => args, "queue" => queue,
102
+ "error_class" => err["exception_class"],
103
+ "error_message" => err["message"],
104
+ "error_backtrace" => err["backtrace"]
105
+ }
106
+ end
107
+
108
+ def retry = @execution.retry if @execution.respond_to?(:retry)
109
+ def delete = @execution.destroy
110
+
111
+ private
112
+
113
+ # Solid Queue stores the full ActiveJob payload; the caller-facing args
114
+ # live under "arguments".
115
+ def active_job_args
116
+ raw = @job&.arguments
117
+ data = raw.is_a?(String) ? JSON.parse(raw) : raw
118
+ data.is_a?(Hash) ? (data["arguments"] || []) : Array(data)
119
+ rescue JSON::ParserError
120
+ []
121
+ end
122
+ end
123
+
124
+ # Value object matching Sidekiq::Stats' reader surface.
125
+ Stats = Struct.new(:processed, :failed, :enqueued, :scheduled, :busy, :dead, :retries, keyword_init: true) do
126
+ def scheduled_size = scheduled
127
+ def retry_size = retries
128
+ def dead_size = dead
129
+ def workers_size = busy
130
+ end
131
+ end
132
+ end
133
+ end
@@ -7,9 +7,15 @@ module RoundhouseUi
7
7
  # Sidekiq.configure_server do |config|
8
8
  # config.server_middleware { |chain| chain.add RoundhouseUi::CancelMiddleware }
9
9
  # end
10
+ #
11
+ # Hot-path cost: near zero. Cancellation.pending? answers "anything cancelled
12
+ # at all?" from a process-local gate (one EXISTS per CHECK_EVERY seconds, not
13
+ # per job); the exact per-job SISMEMBER only runs while cancellations are
14
+ # actually pending. State lives on the Cancellation module because Sidekiq
15
+ # builds a fresh middleware instance per job — ivars here wouldn't survive.
10
16
  class CancelMiddleware
11
17
  def call(_worker, job, _queue)
12
- if RoundhouseUi::Cancellation.cancelled?(job["jid"])
18
+ if RoundhouseUi::Cancellation.pending? && RoundhouseUi::Cancellation.cancelled?(job["jid"])
13
19
  RoundhouseUi::Cancellation.clear!(job["jid"])
14
20
  return # acknowledge without running
15
21
  end
@@ -12,6 +12,9 @@ module RoundhouseUi
12
12
  module Cancellation
13
13
  KEY = "roundhouse:cancelled"
14
14
  TTL = 86_400 # seconds
15
+ CHECK_EVERY = 2.0 # seconds — max staleness of the "nothing is cancelled" gate
16
+
17
+ @gate = Mutex.new # guards @pending / @checked_at (see .pending?)
15
18
 
16
19
  module_function
17
20
 
@@ -20,6 +23,12 @@ module RoundhouseUi
20
23
  conn.call("SADD", KEY, jid.to_s)
21
24
  conn.call("EXPIRE", KEY, TTL)
22
25
  end
26
+ # Bust the local gate: the cancelling process sees its own cancel
27
+ # immediately; other processes converge within CHECK_EVERY.
28
+ @gate.synchronize do
29
+ @pending = true
30
+ @checked_at = monotonic_now
31
+ end
23
32
  end
24
33
 
25
34
  def cancelled?(jid)
@@ -33,5 +42,39 @@ module RoundhouseUi
33
42
  def cancelled_jids
34
43
  Sidekiq.redis { |conn| conn.call("SMEMBERS", KEY) }
35
44
  end
45
+
46
+ # The middleware's hot-path gate: "is anything cancelled at all?" — almost
47
+ # always no, so answering it with one EXISTS per process per CHECK_EVERY
48
+ # (instead of a SISMEMBER per job) takes the common case to zero Redis
49
+ # round-trips. While cancellations are pending, the middleware still does
50
+ # the exact per-job SISMEMBER. A cancel issued by *another* process is
51
+ # invisible here for up to CHECK_EVERY — acceptable because cancellation
52
+ # is cooperative and racy by design (a job may already be running when the
53
+ # flag lands), and cancel! busts the local gate so the cancelling process
54
+ # itself is always exact.
55
+ def pending?
56
+ now = monotonic_now
57
+ @gate.synchronize do
58
+ if @checked_at.nil? || now - @checked_at >= CHECK_EVERY
59
+ @pending = any?
60
+ @checked_at = now
61
+ end
62
+ @pending
63
+ end
64
+ end
65
+
66
+ def any?
67
+ Sidekiq.redis { |conn| conn.call("EXISTS", KEY) } == 1
68
+ end
69
+
70
+ # Forget the cached gate state (tests; or after flushing Redis by hand).
71
+ def reset_gate!
72
+ @gate.synchronize do
73
+ @pending = nil
74
+ @checked_at = nil
75
+ end
76
+ end
77
+
78
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
79
  end
37
80
  end
@@ -1,8 +1,8 @@
1
1
  module RoundhouseUi
2
2
  # Opt-in server middleware that records per-class execution time, so the UI can
3
3
  # answer "which job classes are slow?" — something Sidekiq doesn't track. Two
4
- # cheap Redis writes per job (a counter + a summed-ms float). Off by default;
5
- # enable in your Sidekiq server config:
4
+ # cheap Redis writes per job (a counter + a summed-ms float), pipelined into a
5
+ # single round-trip. Off by default; enable in your Sidekiq server config:
6
6
  #
7
7
  # Sidekiq.configure_server do |config|
8
8
  # config.server_middleware { |chain| chain.add RoundhouseUi::DurationCollector }
@@ -22,9 +22,16 @@ module RoundhouseUi
22
22
  def record(klass, elapsed_ms)
23
23
  return unless klass
24
24
 
25
+ commands = [
26
+ [ "HINCRBY", KEY, "#{klass}\x00count", 1 ],
27
+ [ "HINCRBYFLOAT", KEY, "#{klass}\x00ms", elapsed_ms ]
28
+ ]
25
29
  Sidekiq.redis do |conn|
26
- conn.call("HINCRBY", KEY, "#{klass}\x00count", 1)
27
- conn.call("HINCRBYFLOAT", KEY, "#{klass}\x00ms", elapsed_ms)
30
+ if conn.respond_to?(:pipelined) # redis-client and redis-rb 4.5+: one round-trip
31
+ conn.pipelined { |pipe| commands.each { |c| pipe.call(*c) } }
32
+ else
33
+ commands.each { |c| conn.call(*c) }
34
+ end
28
35
  end
29
36
  rescue => e
30
37
  # Metrics collection must never break a job.
@@ -43,7 +43,7 @@ module RoundhouseUi
43
43
  # Sidekiq's native sets, plus the sidekiq-failures `failed` set when opted in
44
44
  # and loaded. Its FailureSet is a Sidekiq::JobSet, so it iterates like the rest.
45
45
  def sources
46
- sets = { "retry" => Sidekiq::RetrySet.new, "dead" => Sidekiq::DeadSet.new }
46
+ sets = { "retry" => RoundhouseUi.backend.retry_set, "dead" => RoundhouseUi.backend.dead_set }
47
47
  if RoundhouseUi.show_sidekiq_failures && defined?(Sidekiq::Failures::FailureSet)
48
48
  sets["failed"] = Sidekiq::Failures::FailureSet.new
49
49
  end
@@ -7,7 +7,7 @@ module RoundhouseUi
7
7
  # client-side from the dashboard's poll stream; per-class durations need the
8
8
  # collector (a separate, opt-in piece).
9
9
  class Metrics
10
- def initialize(stats: Sidekiq::Stats.new, processes: Sidekiq::ProcessSet.new)
10
+ def initialize(stats: RoundhouseUi.backend.stats, processes: RoundhouseUi.backend.process_set)
11
11
  @stats = stats
12
12
  @processes = processes
13
13
  end
@@ -7,30 +7,59 @@ module RoundhouseUi
7
7
  # Paused queue names live in a Redis set. RoundhouseUi::Fetch consults this set
8
8
  # and skips paused queues when pulling work, so a paused queue stops being
9
9
  # consumed without stopping the worker process.
10
+ #
11
+ # When Sidekiq Pro is loaded we defer to *its* registry instead (see .native?).
12
+ # Pro reopens Sidekiq::Queue with pause!/unpause!/paused? and prepends pause
13
+ # support onto Sidekiq::BasicFetch, so pausing is already enforced with no
14
+ # Roundhouse fetcher installed — and Pro's key ("paused") is not ours
15
+ # ("roundhouse:paused"), so writing our own set there would do nothing.
10
16
  module Pause
11
17
  KEY = "roundhouse:paused"
12
18
  FETCH_FLAG = "roundhouse:fetch_alive" # liveness beacon set by the fetcher
19
+ PRO_KEY = "paused" # Sidekiq Pro's own registry
13
20
 
14
21
  module_function
15
22
 
23
+ # True when Sidekiq Pro's queue-pause API is available. Feature-detected on
24
+ # the method rather than `defined?(Sidekiq::Pro)` so it tracks the actual
25
+ # capability across Pro versions. Cheap (no Redis), so it isn't memoized —
26
+ # loading Pro mid-process would otherwise be missed.
27
+ def native?
28
+ defined?(::Sidekiq::Queue) && ::Sidekiq::Queue.method_defined?(:pause!)
29
+ end
30
+
31
+ # Under Pro, go through Sidekiq::Queue#pause! rather than writing PRO_KEY
32
+ # ourselves: Pro's fetchers read that set once at startup and afterwards only
33
+ # update on the "pro:config" pubsub message that pause! publishes. A bare
34
+ # SADD would leave running workers pulling the queue until they restarted.
16
35
  def pause!(queue)
36
+ return ::Sidekiq::Queue.new(queue.to_s).pause! if native?
37
+
17
38
  Sidekiq.redis { |conn| conn.call("SADD", KEY, queue.to_s) }
18
39
  end
19
40
 
20
41
  def unpause!(queue)
42
+ return ::Sidekiq::Queue.new(queue.to_s).unpause! if native?
43
+
21
44
  Sidekiq.redis { |conn| conn.call("SREM", KEY, queue.to_s) }
22
45
  end
23
46
 
24
47
  def paused?(queue)
25
- Sidekiq.redis { |conn| conn.call("SISMEMBER", KEY, queue.to_s) } == 1
48
+ Sidekiq.redis { |conn| conn.call("SISMEMBER", key, queue.to_s) } == 1
26
49
  end
27
50
 
28
51
  def paused_queues
29
- Sidekiq.redis { |conn| conn.call("SMEMBERS", KEY) }.sort
52
+ Sidekiq.redis { |conn| conn.call("SMEMBERS", key) }.sort
30
53
  end
31
54
 
32
55
  def paused_set
33
- Set.new(Sidekiq.redis { |conn| conn.call("SMEMBERS", KEY) })
56
+ Set.new(Sidekiq.redis { |conn| conn.call("SMEMBERS", key) })
57
+ end
58
+
59
+ # Which registry reads come from. Reads are plain set lookups in both cases
60
+ # (no pubsub involved), so they can share one implementation.
61
+ def key
62
+ native? ? PRO_KEY : KEY
34
63
  end
35
64
 
36
65
  # Given the redis queue keys BasicFetch would poll (e.g. "queue:default"),
@@ -52,7 +81,13 @@ module RoundhouseUi
52
81
 
53
82
  # True when a RoundhouseUi::Fetch has reported in recently — i.e. pausing
54
83
  # will take effect. When false, the UI warns instead of pretending.
84
+ #
85
+ # Under Pro no beacon is needed: Pro prepends pause support onto
86
+ # Sidekiq::BasicFetch (and SuperFetch honors it too), so any Pro worker
87
+ # enforces pauses whether or not our fetcher is installed.
55
88
  def fetch_installed?
89
+ return true if native?
90
+
56
91
  Sidekiq.redis { |conn| conn.call("EXISTS", FETCH_FLAG) } == 1
57
92
  end
58
93
  end
@@ -1,3 +1,3 @@
1
1
  module RoundhouseUi
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.1"
3
3
  end
data/lib/roundhouse_ui.rb CHANGED
@@ -13,6 +13,8 @@ require "roundhouse_ui/metrics"
13
13
  require "roundhouse_ui/error_groups"
14
14
  require "roundhouse_ui/health"
15
15
  require "roundhouse_ui/duration_collector"
16
+ require "roundhouse_ui/backends/sidekiq"
17
+ require "roundhouse_ui/backends/solid_queue"
16
18
 
17
19
  # Brand name is "Roundhouse"; the gem and Ruby namespace are RoundhouseUi
18
20
  # (matching the published gem name `roundhouse_ui`).
@@ -87,6 +89,15 @@ module RoundhouseUi
87
89
  yield self
88
90
  end
89
91
 
92
+ # The queue backend the UI reads through. Defaults to Sidekiq; assign a
93
+ # different adapter (e.g. Solid Queue) to point Roundhouse at another system.
94
+ # See docs/adr/0001-backend-port-multi-queue.md.
95
+ attr_writer :backend
96
+
97
+ def backend
98
+ @backend ||= Backends::Sidekiq.new
99
+ end
100
+
90
101
  # Cooperative cancellation check for long-running jobs:
91
102
  # raise SomeStop if RoundhouseUi.cancelled?(jid)
92
103
  def cancelled?(jid)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roundhouse_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - R.J. Robinson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-01 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -94,6 +94,8 @@ files:
94
94
  - config/routes.rb
95
95
  - lib/roundhouse_ui.rb
96
96
  - lib/roundhouse_ui/audit.rb
97
+ - lib/roundhouse_ui/backends/sidekiq.rb
98
+ - lib/roundhouse_ui/backends/solid_queue.rb
97
99
  - lib/roundhouse_ui/cancel_middleware.rb
98
100
  - lib/roundhouse_ui/cancellation.rb
99
101
  - lib/roundhouse_ui/duration_collector.rb