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 +4 -4
- data/README.md +109 -37
- data/app/controllers/roundhouse_ui/application_controller.rb +6 -0
- data/app/controllers/roundhouse_ui/busy_controller.rb +1 -13
- data/app/controllers/roundhouse_ui/capsules_controller.rb +1 -1
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +4 -4
- data/app/controllers/roundhouse_ui/dead_controller.rb +6 -6
- data/app/controllers/roundhouse_ui/jobs_controller.rb +4 -9
- data/app/controllers/roundhouse_ui/queues_controller.rb +9 -6
- data/app/controllers/roundhouse_ui/retries_controller.rb +5 -5
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -4
- data/app/controllers/roundhouse_ui/workers_controller.rb +2 -2
- data/app/views/layouts/roundhouse_ui/application.html.erb +15 -9
- data/lib/roundhouse_ui/backends/sidekiq.rb +70 -0
- data/lib/roundhouse_ui/backends/solid_queue.rb +133 -0
- data/lib/roundhouse_ui/cancel_middleware.rb +7 -1
- data/lib/roundhouse_ui/cancellation.rb +43 -0
- data/lib/roundhouse_ui/duration_collector.rb +11 -4
- data/lib/roundhouse_ui/error_groups.rb +1 -1
- data/lib/roundhouse_ui/metrics.rb +1 -1
- data/lib/roundhouse_ui/pause.rb +38 -3
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +11 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ee71201b4424c59d9450b7eea1fec5b7447e9e40ee419a4ebc03c39bf8189d6
|
|
4
|
+
data.tar.gz: 8a33d51ba920fd11b64ae9a28160ce40e87ff0b8c315a04136b64c7aa8427bb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/rjrobinson/roundhouse_ui/actions/workflows/ci.yml)
|
|
7
6
|
[](https://rubygems.org/gems/roundhouse_ui)
|
|
@@ -9,32 +8,34 @@
|
|
|
9
8
|
[](https://rubyonrails.org)
|
|
10
9
|
[](MIT-LICENSE)
|
|
11
10
|
|
|
12
|
-
Roundhouse is a mountable Rails engine
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **Queue management** — pause/resume
|
|
27
|
-
- **Job inspection & editing** —
|
|
28
|
-
- **
|
|
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
|
-
|
|
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 >=
|
|
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
|
|
88
|
-
#
|
|
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.
|
|
97
|
-
# DurationCollector middleware (see below).
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
7
|
-
@queues =
|
|
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 =
|
|
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:
|
|
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 =
|
|
11
|
-
@jobs, @has_next = browse(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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 =
|
|
7
|
-
@paused =
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
11
|
-
@jobs, @has_next = browse(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
11
|
-
@jobs, @has_next = browse(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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.
|
|
27
|
-
|
|
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" =>
|
|
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:
|
|
10
|
+
def initialize(stats: RoundhouseUi.backend.stats, processes: RoundhouseUi.backend.process_set)
|
|
11
11
|
@stats = stats
|
|
12
12
|
@processes = processes
|
|
13
13
|
end
|
data/lib/roundhouse_ui/pause.rb
CHANGED
|
@@ -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",
|
|
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",
|
|
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",
|
|
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
|
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.
|
|
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-
|
|
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
|