roundhouse_ui 0.9.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: 913330ea311e66bcb5eb717852a73c0c4eb51a281acbd5899fac33f623b609a2
4
- data.tar.gz: 8fdaff5a284e27d7df7a63ade47294f9d5f156a14e9133f7a40a24ca194ad828
3
+ metadata.gz: 6ee71201b4424c59d9450b7eea1fec5b7447e9e40ee419a4ebc03c39bf8189d6
4
+ data.tar.gz: 8a33d51ba920fd11b64ae9a28160ce40e87ff0b8c315a04136b64c7aa8427bb7
5
5
  SHA512:
6
- metadata.gz: ae78d8ec53e92eaac58308a7701abfc11f71ad2d89f1ffe05b40611df458a6121a97f831d16770dfb0f9d37a3bdffe50905498bba41e7c86c7b74fe4bbc211d3
7
- data.tar.gz: 446a6fa76c6f2cf70f2b3ec5e83d5eb06dbe6e6a393bebfab7414f3bd1e87d8fa96c6bf9ae180c284e2ae50e63ee1e1f3516a827591ab58d686fccdd8ebaf195
6
+ metadata.gz: 7494f64a2e7cea227f13ac6037e390775f068726f81e3962ecf0bf62a87d6dffaa7c613bef9c0d2b47bf4bdc5d410e6a2073e35c3a67723485af99fd30b43691
7
+ data.tar.gz: 2ff606ac7ca4797069b7cbb875313fd7988a962845f4c738765a8816a48280f5c889167a31d43452342389c3d122d798b15b782ac2219ba0cfb99d4125431bc0
data/README.md CHANGED
@@ -107,32 +107,52 @@ RoundhouseUi.configure do |c|
107
107
  # No-op unless the sidekiq-failures gem is loaded. Default: off.
108
108
  c.show_sidekiq_failures = true
109
109
 
110
- # Set false to hide queue pause/resume controls and the "not enforced" warning,
111
- # e.g. when you run reliable fetch (super_fetch) instead of RoundhouseUi::Fetch.
112
- # 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.
113
113
  # c.pause_enabled = false
114
114
 
115
115
  # Seconds between dashboard stat polls (default 5). Raise it if polling shows
116
116
  # up in your traces — each poll re-runs the host's auth/routing on the mount.
117
117
  # c.poll_interval = 10
118
118
 
119
- # Show the "slowest job classes" table on the Metrics page. Requires the
120
- # 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.
121
122
  # c.collect_durations = true
122
123
  end
123
124
  ```
124
125
 
125
- Every option is independent and has a safe default — set only what you need.
126
- `read_only`, `allow_job_editing`, and `show_sidekiq_failures` default to `false`;
127
- `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).
128
147
 
129
148
  ## Pausing queues
130
149
 
131
- > On **Solid Queue**, pause is **native** — it's enforced by the backend, so there's
132
- > nothing to install and no warning. The rest of this section is Sidekiq-only.
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**.
133
153
 
134
- On Sidekiq, pause/resume is pure OSS. To make a pause actually stop a queue from being
135
- worked, install Roundhouse's fetch strategy in your Sidekiq **server** config:
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:
136
156
 
137
157
  ```ruby
138
158
  # config/initializers/sidekiq.rb
@@ -146,11 +166,26 @@ all of Sidekiq's weighting/ordering. Until it's installed, the Queues page recor
146
166
  but **warns that they aren't enforced** (worker and web are separate processes, so
147
167
  Roundhouse detects whether a fetcher has reported in).
148
168
 
149
- > ⚠️ **Sidekiq Pro/Enterprise users:** super_fetch / reliable fetch sets its own
150
- > `fetch_class`, and Sidekiq allows only one. Installing `RoundhouseUi::Fetch` would
151
- > **replace reliable fetch and lose its crash-recovery guarantees** don't. On those
152
- > tiers, leave the fetch strategy out and set `RoundhouseUi.pause_enabled = false` to
153
- > 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.
154
189
 
155
190
  ## Surfacing sidekiq-failures
156
191
 
@@ -184,6 +219,15 @@ The **Busy** page's Cancel button flags a job's JID. A queued/scheduled/retrying
184
219
  is then skipped when it would next run; a *currently running* job stops only if it
185
220
  checks in — e.g. a long loop can `break if RoundhouseUi.cancelled?(jid)`.
186
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
+
187
231
  ## Slowest job classes
188
232
 
189
233
  Sidekiq doesn't track per-class durations, so Roundhouse can record them itself.
@@ -197,8 +241,8 @@ Sidekiq.configure_server do |config|
197
241
  end
198
242
  ```
199
243
 
200
- It's two cheap Redis writes per job (a counter + a summed-ms float) into a single
201
- 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.
202
246
 
203
247
  ## Bulk actions on a filter
204
248
 
@@ -14,10 +14,17 @@ module RoundhouseUi
14
14
  def name = "Sidekiq"
15
15
 
16
16
  # Capabilities let the UI hide what a backend can't do. Sidekiq supports
17
- # all the sets/views; pause is NOT native (needs the fetcher), so it does
18
- # not advertise :native_pause the "not enforced" warning still applies.
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.
19
21
  CAPABILITIES = %i[retries dead scheduled busy workers redis capsules].freeze
20
- def supports?(capability) = CAPABILITIES.include?(capability)
22
+
23
+ def supports?(capability)
24
+ return RoundhouseUi::Pause.native? if capability == :native_pause
25
+
26
+ CAPABILITIES.include?(capability)
27
+ end
21
28
 
22
29
  def stats = ::Sidekiq::Stats.new
23
30
  def queues = ::Sidekiq::Queue.all
@@ -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.
@@ -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.9.0"
2
+ VERSION = "0.9.1"
3
3
  end
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.9.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-24 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