wurk 1.1.0 → 1.1.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 +25 -0
- data/app/controllers/concerns/wurk/same_origin_guard.rb +40 -0
- data/app/controllers/concerns/wurk/sse_streaming.rb +48 -0
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +53 -0
- data/app/controllers/wurk/api/pagination.rb +60 -11
- data/app/controllers/wurk/api/serializers.rb +5 -1
- data/app/controllers/wurk/api_controller.rb +51 -70
- data/app/controllers/wurk/application_controller.rb +26 -0
- data/app/controllers/wurk/dashboard_controller.rb +23 -1
- data/app/controllers/wurk/extensions_controller.rb +3 -12
- data/app/controllers/wurk/profiles_controller.rb +6 -1
- data/lib/wurk/batch/death_handler.rb +13 -0
- data/lib/wurk/batch/server_middleware.rb +9 -5
- data/lib/wurk/batch.rb +3 -0
- data/lib/wurk/capsule.rb +34 -21
- data/lib/wurk/cli.rb +16 -3
- data/lib/wurk/client/buffered.rb +30 -10
- data/lib/wurk/client.rb +45 -1
- data/lib/wurk/component.rb +23 -9
- data/lib/wurk/configuration.rb +83 -18
- data/lib/wurk/cron.rb +13 -1
- data/lib/wurk/dead_set.rb +16 -1
- data/lib/wurk/engine.rb +17 -1
- data/lib/wurk/fetcher/reliable.rb +46 -13
- data/lib/wurk/fetcher.rb +5 -0
- data/lib/wurk/health.rb +74 -22
- data/lib/wurk/history.rb +4 -21
- data/lib/wurk/job_retry.rb +3 -5
- data/lib/wurk/launcher.rb +26 -3
- data/lib/wurk/limiter/server_middleware.rb +16 -1
- data/lib/wurk/limiter.rb +6 -5
- data/lib/wurk/lua/loader.rb +11 -5
- data/lib/wurk/lua.rb +100 -9
- data/lib/wurk/manager.rb +59 -19
- data/lib/wurk/metrics/queue_rollup.rb +4 -21
- data/lib/wurk/metrics/rollup.rb +4 -21
- data/lib/wurk/processor.rb +8 -8
- data/lib/wurk/profile_set.rb +21 -6
- data/lib/wurk/rails_boot.rb +176 -0
- data/lib/wurk/railtie.rb +19 -47
- data/lib/wurk/redis_connection.rb +6 -9
- data/lib/wurk/redis_pool.rb +148 -28
- data/lib/wurk/scheduled.rb +35 -12
- data/lib/wurk/swarm/backoff.rb +70 -0
- data/lib/wurk/swarm/child_boot.rb +92 -13
- data/lib/wurk/swarm/orphan_guard.rb +105 -0
- data/lib/wurk/swarm/restart.rb +196 -0
- data/lib/wurk/swarm.rb +194 -78
- data/lib/wurk/timer_loop.rb +49 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/extension.rb +4 -1
- data/lib/wurk/web/pool_scope.rb +46 -0
- data/lib/wurk/web/rack_app.rb +2 -1
- data/lib/wurk/web/search.rb +77 -18
- data/lib/wurk/web.rb +1 -0
- data/lib/wurk/worker/setter.rb +6 -1
- data/lib/wurk.rb +6 -2
- data/vendor/assets/dashboard/assets/ArgsValue-DYfBiXrJ.js +1 -0
- data/vendor/assets/dashboard/assets/BatchDetail-DTZ2HzcD.js +1 -0
- data/vendor/assets/dashboard/assets/Batches-BrnXA332.js +1 -0
- data/vendor/assets/dashboard/assets/Busy-C57G8Xb3.js +1 -0
- data/vendor/assets/dashboard/assets/Cron-DlyH88oo.js +1 -0
- data/vendor/assets/dashboard/assets/Dashboard-B887pxWf.js +1 -0
- data/vendor/assets/dashboard/assets/Dead-BPA7gs-X.js +1 -0
- data/vendor/assets/dashboard/assets/Extension-Bunf6XuU.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-FCDi4ZCU.js +1 -0
- data/vendor/assets/dashboard/assets/JobDetailModal-DuMdKUMm.js +2 -0
- data/vendor/assets/dashboard/assets/Limiters-Br0aCPMK.js +1 -0
- data/vendor/assets/dashboard/assets/Metrics-DxBmuywH.js +1 -0
- data/vendor/assets/dashboard/assets/Modal-t4FI_LaY.js +1 -0
- data/vendor/assets/dashboard/assets/PageHeader-CsDvJSOA.js +1 -0
- data/vendor/assets/dashboard/assets/Profiles-Bkhoqjlq.js +1 -0
- data/vendor/assets/dashboard/assets/Queues-BvhA-vfI.js +1 -0
- data/vendor/assets/dashboard/assets/Retries-JEpB-1Yl.js +1 -0
- data/vendor/assets/dashboard/assets/Scheduled-DN_FbSwP.js +1 -0
- data/vendor/assets/dashboard/assets/Search-DJuK0YCJ.js +1 -0
- data/vendor/assets/dashboard/assets/Skeleton-DOYDkzg1.js +1 -0
- data/vendor/assets/dashboard/assets/charts-CVK0zAnC.js +1 -0
- data/vendor/assets/dashboard/assets/index-BnPX9Ptn.css +1 -0
- data/vendor/assets/dashboard/assets/index-CZTcs-pM.js +141 -0
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-CoZU4b3a.js +1 -0
- data/vendor/assets/dashboard/assets/useSort-wQcnbdsa.js +1 -0
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +55 -26
- data/vendor/assets/dashboard/assets/ArgsValue-BUqJa-eG.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-C5dkqAzp.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-bgkXn8tc.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-QSHBFXhy.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-CUHppvTA.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-CzPudckV.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-BIq4Nz_i.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-CW36i9R1.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-BhNdWSp7.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-CiI_DfUK.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-BzZ8ugms.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-DzVfgsSF.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-Dp3qhX3e.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-BTtIlTdR.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-BuXoxQ4W.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-D4HAPaOQ.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-6NCZYVJh.js +0 -1
- data/vendor/assets/dashboard/assets/Search-JaB_-52c.js +0 -1
- data/vendor/assets/dashboard/assets/charts-6uvCyY0x.js +0 -1
- data/vendor/assets/dashboard/assets/i18n-gIeA5VLo.js +0 -1
- data/vendor/assets/dashboard/assets/index-BDG9tvBA.css +0 -1
- data/vendor/assets/dashboard/assets/index-DWfWAGBc.js +0 -141
- data/vendor/assets/dashboard/assets/useJobSetActions-DTDaAoWr.js +0 -1
- data/vendor/assets/dashboard/assets/usePageParam-BcAvRko-.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-D5Am4bGq.js +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8911da9e550c5d8078c4293ebb69f3b8620a4285692f6e3a6b37d41b711c9219
|
|
4
|
+
data.tar.gz: 9cc0cf226409e7fb5f9ffce6749b2a7328324e9319350d7752c8bdf8d33e7437
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '093767442c103fc2a99fc8b8d0b5fa21c16d50a46cd90be6826b948c82c871217e725c07af71b6df3c98d501788199104601262081cfe8810b7bbd510c099191'
|
|
7
|
+
data.tar.gz: fe4977ee515f59cbb6758fe4f42a5d6a8a198c863efc5bebccd211e1c47a2f1ff3f11d90ce85ad1e7faa8f335e1b865ff0bb8efbc0a827773c4d977c968e149b
|
data/README.md
CHANGED
|
@@ -75,6 +75,25 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
|
|
|
75
75
|
|
|
76
76
|
JRuby, TruffleRuby, and Windows fall back to threads-only mode (no fork) — behaviorally equivalent to stock Sidekiq.
|
|
77
77
|
|
|
78
|
+
## Running the workers
|
|
79
|
+
|
|
80
|
+
Under Rails the engine **auto-starts** the swarm on boot — a plain `rails server` already forks workers and fetches. Set `WURK_DISABLED=1` on any process that shouldn't (e.g. the web tier when you run workers on their own dyno).
|
|
81
|
+
|
|
82
|
+
**One exception — preforking web servers.** A clustered Puma, Unicorn, or Passenger forks its own web workers, so Wurk **refuses** to also fork the swarm there — it would multiply the swarm by the web-worker count, or entangle its supervisor with the server's own fork/signal handling — and logs how to proceed. Either run the swarm as its own process (recommended):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
bundle exec wurkswarm # forked swarm, real parallelism
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
…or run it inside the web process as threads only, no fork (like Sidekiq embedded):
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# config/application.rb (here, not an initializer — server mode is decided before initializers load)
|
|
92
|
+
config.wurk.embed_in_web = true
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Single-mode Puma (the `rails server` default) isn't preforking, so auto-start is unaffected. Full details, flags, and the standalone runners: **[Starting the worker](https://github.com/developerz-ai/wurk/blob/main/docs/running.md)**.
|
|
96
|
+
|
|
78
97
|
## The dashboard
|
|
79
98
|
|
|
80
99
|
Mount the engine wherever you like:
|
|
@@ -92,6 +111,12 @@ Wurk::Web.use(Rack::Auth::Basic, "Wurk") { |user, pass| user == ENV["WURK_USER"]
|
|
|
92
111
|
|
|
93
112
|
Ship a viewer-only board (e.g. a public demo) with no auth code at all by setting `WURK_WEB_READ_ONLY=1` — every mutating request returns 403 and the SPA hides destructive actions.
|
|
94
113
|
|
|
114
|
+
### Security notes
|
|
115
|
+
|
|
116
|
+
- **`Wurk::Web.use` and the `authorization` hook gate the dashboard's routes and JSON API** — every controller under the engine mount goes through them.
|
|
117
|
+
- **`/wurk-assets/*` (the precompiled SPA's JS/CSS/font bundle) is served unauthenticated, by design.** It's inserted into the host app's own middleware stack ahead of the engine's routes, so it never reaches `Wurk::Web.use`/`authorization`. This is safe because the bundle carries no data — no job payloads, no Redis reads, nothing per-user — it's a static shell, same trust model as any Rails app's `public/assets`. Everything data-bearing (stats, queues, jobs) is served by the JSON API, which *is* gated. If you need to hide even the existence of the bundle (e.g. compliance requires the mount path itself stay secret), put a reverse-proxy rule in front of `/wurk-assets` rather than relying on the engine.
|
|
118
|
+
- Redis being unreachable surfaces to the SPA as a structured `503 {"error": "redis_unavailable"}` (JSON endpoints) or an SSE `error` event (the live stream), never a raw 500 — the client can branch on it instead of parsing an HTML error page.
|
|
119
|
+
|
|
95
120
|
## Encryption
|
|
96
121
|
|
|
97
122
|
A drop-in for `Sidekiq::Enterprise::Crypto`. It encrypts the **last** positional argument of a job with AES-256-GCM — the client middleware seals it on push, the server middleware opens it before `perform`. Earlier args stay plaintext so you can still triage on `user_id`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Sidekiq's CSRF model (spec §25.1) for the dashboard's token-less, cookie-
|
|
5
|
+
# authenticated surfaces. The SolidJS SPA (JSON fetch) and host-registered
|
|
6
|
+
# extension forms send no Rails authenticity token, so `protect_from_forgery`
|
|
7
|
+
# can't apply — guard unsafe methods with a same-origin check instead. A
|
|
8
|
+
# cross-site page can drive a victim's browser but cannot forge
|
|
9
|
+
# `Sec-Fetch-Site: same-origin` (the browser sets it); a missing header is
|
|
10
|
+
# denied too, since only a non-browser client omits it and it carries no
|
|
11
|
+
# ambient session cookie to abuse.
|
|
12
|
+
#
|
|
13
|
+
# Mirrors the standalone Rack surface (Wurk::Web#safe_request?) so every
|
|
14
|
+
# mutating dashboard entry point enforces one rule.
|
|
15
|
+
module SameOriginGuard
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Non-mutating methods need no origin check. Matches Wurk::Web::SAFE_METHODS;
|
|
19
|
+
# kept local so this engine concern carries no lib load-order dependency.
|
|
20
|
+
SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
# SPA/extension requests never carry a Rails CSRF token; the same-origin
|
|
24
|
+
# check below is the CSRF defense in its place.
|
|
25
|
+
skip_forgery_protection
|
|
26
|
+
before_action :verify_same_origin!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def verify_same_origin!
|
|
32
|
+
head :forbidden unless safe_request?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def safe_request?
|
|
36
|
+
SAFE_METHODS.include?(request.request_method) ||
|
|
37
|
+
request.headers['Sec-Fetch-Site'] == 'same-origin'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Drives the `/api/stream` SSE loop (headers, tick cadence, tear-down).
|
|
5
|
+
# Split out of ApiController so the periodic-stats-push mechanics — which
|
|
6
|
+
# don't change per action — stay separate from the request/response mapping
|
|
7
|
+
# ApiController owns.
|
|
8
|
+
module SseStreaming
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def stream_headers!
|
|
14
|
+
response.headers['Content-Type'] = 'text/event-stream'
|
|
15
|
+
response.headers['Cache-Control'] = 'no-cache'
|
|
16
|
+
response.headers['X-Accel-Buffering'] = 'no'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def drive_stream(sse, tick, max_dur)
|
|
20
|
+
deadline = monotime + max_dur
|
|
21
|
+
loop do
|
|
22
|
+
sse.write(stream_tick_payload, event: 'stats')
|
|
23
|
+
break if monotime >= deadline
|
|
24
|
+
|
|
25
|
+
sleep tick
|
|
26
|
+
end
|
|
27
|
+
rescue ::IOError, ::ActionController::Live::ClientDisconnected
|
|
28
|
+
# client closed; nothing to clean up.
|
|
29
|
+
rescue *::Wurk::Configuration::REDIS_ERROR_CLASSES => e
|
|
30
|
+
# Headers are already flushed by the time a tick hits Redis, so the
|
|
31
|
+
# 503 ApplicationController's rescue_from renders for a plain request
|
|
32
|
+
# can't happen here — emit a structured SSE event instead and close;
|
|
33
|
+
# the SPA's EventSource reconnects and gets a fresh chance at Redis.
|
|
34
|
+
logger.warn("wurk web: stream redis unavailable (#{e.class}: #{e.message})")
|
|
35
|
+
sse.write({ error: 'redis_unavailable' }, event: 'error')
|
|
36
|
+
ensure
|
|
37
|
+
sse.close
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def monotime
|
|
41
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stream_tick_payload
|
|
45
|
+
::Wurk::Api::Serializers.stats_payload(::Wurk::Stats.new).merge(at: ::Time.now.to_f)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Per-process cap on concurrent SSE streams. ActionController::Live pins one
|
|
5
|
+
# Puma thread for every open `/api/stream`; without a bound, a burst of stale
|
|
6
|
+
# browser tabs could hold every thread and starve the JSON API. Past the cap
|
|
7
|
+
# we 503 with Retry-After — the SPA's EventSource reconnects (and its polling
|
|
8
|
+
# fallback honors Retry-After) once a slot frees. Per-process is the right
|
|
9
|
+
# scope: it's this process's own thread pool we're protecting.
|
|
10
|
+
module StreamConcurrencyGuard
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
MAX_CONCURRENT_STREAMS = 10
|
|
14
|
+
RETRY_AFTER_SECONDS = 3
|
|
15
|
+
|
|
16
|
+
@open = 0
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Reserve a stream slot; false when the cap is already reached.
|
|
21
|
+
def acquire
|
|
22
|
+
@lock.synchronize do
|
|
23
|
+
return false if @open >= MAX_CONCURRENT_STREAMS
|
|
24
|
+
|
|
25
|
+
@open += 1
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def release
|
|
31
|
+
@lock.synchronize { @open -= 1 if @open.positive? }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Runs the SSE body only while holding a slot, releasing it however the
|
|
38
|
+
# stream ends (client disconnect, cap-duration, error). 503 + Retry-After
|
|
39
|
+
# past the cap instead of tying up a thread on an unservable request.
|
|
40
|
+
def with_stream_slot
|
|
41
|
+
unless StreamConcurrencyGuard.acquire
|
|
42
|
+
response.headers['Retry-After'] = RETRY_AFTER_SECONDS.to_s
|
|
43
|
+
return head :service_unavailable
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
yield
|
|
48
|
+
ensure
|
|
49
|
+
StreamConcurrencyGuard.release
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -13,12 +13,22 @@ module Wurk
|
|
|
13
13
|
module Pagination
|
|
14
14
|
DEFAULT_PAGE_SIZE = 25
|
|
15
15
|
MAX_PAGE_SIZE = 200
|
|
16
|
+
# Offset pagination reaches page N by walking N*count rows through Ruby
|
|
17
|
+
# (and their Redis round-trips). An unclamped ?page= lets one request
|
|
18
|
+
# walk an entire million-row set; 1000 pages is far beyond any real UI
|
|
19
|
+
# depth while bounding the worst-case walk.
|
|
20
|
+
MAX_PAGE = 1_000
|
|
21
|
+
# A substr that matches nothing would otherwise stream the whole backing
|
|
22
|
+
# set through Ruby (same DoS shape Web::Search bounds with SCAN_BUDGET).
|
|
23
|
+
# Raw rows examined per filtered request; results within the budget are
|
|
24
|
+
# exact, beyond it the page just comes back short.
|
|
25
|
+
FILTER_SCAN_LIMIT = 20_000
|
|
16
26
|
|
|
17
27
|
module_function
|
|
18
28
|
|
|
19
29
|
def window(params)
|
|
20
30
|
{
|
|
21
|
-
page:
|
|
31
|
+
page: clamp_int(params[:page], 0, MAX_PAGE, 0),
|
|
22
32
|
count: clamp_int(params[:count], 1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE),
|
|
23
33
|
substr: params[:substr].to_s
|
|
24
34
|
}
|
|
@@ -39,19 +49,58 @@ module Wurk
|
|
|
39
49
|
# Iterates `enumerable` and collects up to `count` payloads from the
|
|
40
50
|
# requested page after substr filtering. `block` maps each member to a
|
|
41
51
|
# JSON Hash; nil from the block skips the member entirely.
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
#
|
|
53
|
+
# With a substr filter the offset must count *matching* rows, not raw
|
|
54
|
+
# rows — otherwise page N restarts at raw index N*count and re-emits
|
|
55
|
+
# (or skips) matches already assigned to earlier pages. Without a
|
|
56
|
+
# filter we keep the fast path that skips serialization before the
|
|
57
|
+
# offset.
|
|
58
|
+
def slice(enumerable, page, &)
|
|
44
59
|
offset = page[:page] * page[:count]
|
|
45
|
-
|
|
60
|
+
if page[:substr].empty?
|
|
61
|
+
slice_raw(enumerable, offset, page[:count], &)
|
|
62
|
+
else
|
|
63
|
+
slice_filtered(enumerable, offset, page, &)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Fast path: skip serialization entirely for rows before the offset.
|
|
68
|
+
def slice_raw(enumerable, offset, count)
|
|
69
|
+
results = []
|
|
70
|
+
seen = 0
|
|
71
|
+
enumerable.each do |member|
|
|
72
|
+
if seen < offset
|
|
73
|
+
seen += 1
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
payload = yield(member)
|
|
78
|
+
next unless payload
|
|
79
|
+
|
|
80
|
+
results << payload
|
|
81
|
+
break if results.size >= count
|
|
82
|
+
end
|
|
83
|
+
results
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Filtered path: the offset counts MATCHING rows, so page boundaries
|
|
87
|
+
# stay stable (raw-index offsets re-emit or skip matches across pages).
|
|
88
|
+
def slice_filtered(enumerable, offset, page)
|
|
89
|
+
results = []
|
|
90
|
+
matched = 0
|
|
91
|
+
examined = 0
|
|
46
92
|
enumerable.each do |member|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
93
|
+
examined += 1
|
|
94
|
+
break if examined > FILTER_SCAN_LIMIT
|
|
95
|
+
|
|
96
|
+
payload = yield(member)
|
|
97
|
+
next unless payload && match?(payload, page[:substr])
|
|
98
|
+
|
|
99
|
+
if matched >= offset
|
|
100
|
+
results << payload
|
|
101
|
+
break if results.size >= page[:count]
|
|
53
102
|
end
|
|
54
|
-
|
|
103
|
+
matched += 1
|
|
55
104
|
end
|
|
56
105
|
results
|
|
57
106
|
end
|
|
@@ -63,7 +63,10 @@ module Wurk
|
|
|
63
63
|
)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
# `leader_identity` is the cluster's `dear-leader` value (ProcessSet#leader,
|
|
67
|
+
# memoized to one Redis GET per request) — comparing here avoids the N+1
|
|
68
|
+
# `Process#leader?` GET-per-row that a per-process lookup would cost.
|
|
69
|
+
def process_row(process, leader_identity: nil)
|
|
67
70
|
{
|
|
68
71
|
identity: process.identity,
|
|
69
72
|
hostname: process['hostname'],
|
|
@@ -73,6 +76,7 @@ module Wurk
|
|
|
73
76
|
busy: process['busy'],
|
|
74
77
|
beat: process['beat'],
|
|
75
78
|
quiet: process.stopping?,
|
|
79
|
+
leader: !leader_identity.to_s.empty? && process.identity == leader_identity,
|
|
76
80
|
rss: process['rss'],
|
|
77
81
|
rtt_us: process['rtt_us'],
|
|
78
82
|
started_at: process['started_at'],
|
|
@@ -15,22 +15,26 @@ module Wurk
|
|
|
15
15
|
# in `docs/target/sidekiq-{free,pro,ent}.md`.
|
|
16
16
|
class ApiController < ApplicationController
|
|
17
17
|
include ActionController::Live
|
|
18
|
+
# The SPA is a token-less JSON client; SameOriginGuard supplies Sidekiq's
|
|
19
|
+
# same-origin CSRF defense (spec §25.1) so every mutating endpoint is
|
|
20
|
+
# protected — GET reads (incl. #stream SSE) stay reachable.
|
|
21
|
+
include SameOriginGuard
|
|
22
|
+
# Bounds concurrent SSE streams per process (503 past the cap) so stale
|
|
23
|
+
# dashboard tabs can't pin every Puma thread. Provides #with_stream_slot.
|
|
24
|
+
include StreamConcurrencyGuard
|
|
25
|
+
# Owns the #stream action's tick loop (headers, cadence, tear-down).
|
|
26
|
+
include SseStreaming
|
|
18
27
|
|
|
19
28
|
STREAM_TICK_SECONDS = 2.0
|
|
20
|
-
STREAM_MAX_DURATION =
|
|
29
|
+
STREAM_MAX_DURATION = 120.0
|
|
30
|
+
# Positive floor for `?tick=`: a zero/negative tick would `sleep 0` the SSE
|
|
31
|
+
# loop into a tight Redis-read/write spin (Live runs it in a spawned thread)
|
|
32
|
+
# for up to STREAM_MAX_DURATION. Clamp before it reaches drive_stream.
|
|
33
|
+
STREAM_MIN_TICK_SECONDS = 0.1
|
|
21
34
|
|
|
22
35
|
HISTORY_WINDOW_UNITS = { 's' => 1, 'm' => 60, 'h' => 3600, 'd' => 86_400 }.freeze
|
|
23
36
|
DEFAULT_HISTORY_WINDOW = 24 * 3600
|
|
24
37
|
|
|
25
|
-
skip_forgery_protection only: %i[
|
|
26
|
-
stream reset_limiter pause_cron unpause_cron enqueue_cron
|
|
27
|
-
clear_queue delete_queue_job pause_queue unpause_queue
|
|
28
|
-
retries_bulk retries_all retry_job
|
|
29
|
-
scheduled_bulk scheduled_all scheduled_job
|
|
30
|
-
dead_bulk dead_all dead_job
|
|
31
|
-
quiet_process stop_process
|
|
32
|
-
]
|
|
33
|
-
|
|
34
38
|
# Per-set action whitelists. Maps the SPA's action name to the
|
|
35
39
|
# SortedEntry/JobSet method. Anything not listed 400s — keeps the bulk/
|
|
36
40
|
# single dispatchers from reaching arbitrary methods off a request param.
|
|
@@ -75,13 +79,13 @@ module Wurk
|
|
|
75
79
|
render json: { ok: true }
|
|
76
80
|
end
|
|
77
81
|
|
|
78
|
-
# Removes a single job from a queue by jid
|
|
79
|
-
#
|
|
82
|
+
# Removes a single job from a queue by jid — one Lua round-trip
|
|
83
|
+
# (Fast::QueueExt#delete_job) instead of a paged find_job walk.
|
|
80
84
|
def delete_queue_job
|
|
81
|
-
|
|
82
|
-
return render(json: { error: 'unknown job' }, status: :not_found)
|
|
85
|
+
removed = ::Wurk::Queue.new(params[:name].to_s).delete_job(params[:jid].to_s)
|
|
86
|
+
return render(json: { error: 'unknown job' }, status: :not_found) if removed.zero?
|
|
83
87
|
|
|
84
|
-
render json: { ok: true, deleted:
|
|
88
|
+
render json: { ok: true, deleted: true }
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
# Pause/unpause a queue (Pro §6, §10.1). Idempotent; returns the resulting
|
|
@@ -144,7 +148,9 @@ module Wurk
|
|
|
144
148
|
end
|
|
145
149
|
|
|
146
150
|
def processes
|
|
147
|
-
|
|
151
|
+
set = ::Wurk::ProcessSet.new
|
|
152
|
+
leader_identity = set.leader
|
|
153
|
+
render json: set.map { |p| ::Wurk::Api::Serializers.process_row(p, leader_identity: leader_identity) }
|
|
148
154
|
end
|
|
149
155
|
|
|
150
156
|
# Currently-executing jobs across the cluster (WorkSet), oldest first.
|
|
@@ -265,10 +271,11 @@ module Wurk
|
|
|
265
271
|
|
|
266
272
|
def search
|
|
267
273
|
substr = params[:substr].to_s
|
|
268
|
-
return render(json: { substr: substr, total: 0, hits: [] }) if substr.empty?
|
|
274
|
+
return render(json: { substr: substr, total: 0, hits: [], truncated: false }) if substr.empty?
|
|
269
275
|
|
|
270
|
-
|
|
271
|
-
|
|
276
|
+
search = ::Wurk::Web::Search.new(substr, kinds: parse_search_kinds(params), limit: parse_search_limit(params))
|
|
277
|
+
hits = search.to_a
|
|
278
|
+
render json: { substr: substr, total: hits.size, hits: hits, truncated: search.truncated? }
|
|
272
279
|
end
|
|
273
280
|
|
|
274
281
|
# Profiles list (v8.0+). The SPA links each row to /profiles/:key (view)
|
|
@@ -281,16 +288,20 @@ module Wurk
|
|
|
281
288
|
# SSE: one `event: stats` per tick with a fresh Stats snapshot. Caps at
|
|
282
289
|
# `STREAM_MAX_DURATION` so a stale browser tab can't tie a Rails worker
|
|
283
290
|
# forever — the client reconnects automatically when the stream closes.
|
|
291
|
+
# Bounded per process by StreamConcurrencyGuard (503 + Retry-After past the
|
|
292
|
+
# cap) so a burst of tabs can't pin every Puma thread.
|
|
284
293
|
#
|
|
285
294
|
# `?max_duration=` and `?tick=` are test/debug knobs; the SPA never sets
|
|
286
295
|
# them. `?max_duration=0` emits one tick and closes.
|
|
287
296
|
def stream
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
297
|
+
with_stream_slot do
|
|
298
|
+
stream_headers!
|
|
299
|
+
clamp = ::Wurk::Api::Pagination.method(:clamp_float)
|
|
300
|
+
tick = clamp.call(params[:tick], STREAM_MIN_TICK_SECONDS, STREAM_TICK_SECONDS, STREAM_TICK_SECONDS)
|
|
301
|
+
max_dur = clamp.call(params[:max_duration], 0.0, STREAM_MAX_DURATION, STREAM_MAX_DURATION)
|
|
302
|
+
sse = ::ActionController::Live::SSE.new(response.stream, retry: (STREAM_TICK_SECONDS * 1000).to_i)
|
|
303
|
+
drive_stream(sse, tick, max_dur)
|
|
304
|
+
end
|
|
294
305
|
end
|
|
295
306
|
|
|
296
307
|
private
|
|
@@ -337,6 +348,11 @@ module Wurk
|
|
|
337
348
|
|
|
338
349
|
count = 0
|
|
339
350
|
keys = Array(params[:keys]).map(&:to_s).uniq
|
|
351
|
+
# Each key costs a ZRANGEBYSCORE plus per-entry mutations; an uncapped
|
|
352
|
+
# list lets one POST issue tens of thousands of round-trips. The UI
|
|
353
|
+
# selects at most a page (200).
|
|
354
|
+
return render(json: { error: 'too many keys (max 1000)' }, status: :bad_request) if keys.size > 1000
|
|
355
|
+
|
|
340
356
|
entries_for(set, keys).each do |entry|
|
|
341
357
|
entry.public_send(method)
|
|
342
358
|
count += 1
|
|
@@ -382,18 +398,16 @@ module Wurk
|
|
|
382
398
|
total
|
|
383
399
|
end
|
|
384
400
|
|
|
385
|
-
# Drains a set by applying `method` to every entry
|
|
386
|
-
#
|
|
387
|
-
#
|
|
401
|
+
# Drains a set by applying `method` to every entry. Snapshot first, then
|
|
402
|
+
# apply: mutating while the paged iterator scans would shift ZRANGE
|
|
403
|
+
# offsets mid-scan, and the old "loop until empty" alternative never
|
|
404
|
+
# terminates on a busy app (concurrent perform_in keeps the set non-empty)
|
|
405
|
+
# while wrongly enqueueing jobs scheduled *after* the operator clicked.
|
|
406
|
+
# Entries removed concurrently no-op their apply.
|
|
388
407
|
def drain_set(set, method)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
entry.public_send(method)
|
|
393
|
-
count += 1
|
|
394
|
-
end
|
|
395
|
-
end
|
|
396
|
-
count
|
|
408
|
+
entries = set.to_a
|
|
409
|
+
entries.each { |entry| entry.public_send(method) }
|
|
410
|
+
entries.size
|
|
397
411
|
end
|
|
398
412
|
|
|
399
413
|
def render_sorted_set(set)
|
|
@@ -408,41 +422,8 @@ module Wurk
|
|
|
408
422
|
# reads in limiter_row (which folds in live status).
|
|
409
423
|
def limiter_rows(names, page)
|
|
410
424
|
(names.slice(page[:page] * page[:count], page[:count]) || []).map do |name|
|
|
411
|
-
::Wurk::Api::Serializers.limiter_row(name,
|
|
412
|
-
end
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
def limiter_meta(name)
|
|
416
|
-
raw = ::Wurk.redis { |c| c.call('HGETALL', "lmtr:#{name}") }
|
|
417
|
-
raw.is_a?(Array) ? raw.each_slice(2).to_h : raw
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
def stream_headers!
|
|
421
|
-
response.headers['Content-Type'] = 'text/event-stream'
|
|
422
|
-
response.headers['Cache-Control'] = 'no-cache'
|
|
423
|
-
response.headers['X-Accel-Buffering'] = 'no'
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
def drive_stream(sse, tick, max_dur)
|
|
427
|
-
deadline = monotime + max_dur
|
|
428
|
-
loop do
|
|
429
|
-
sse.write(stream_tick_payload, event: 'stats')
|
|
430
|
-
break if monotime >= deadline
|
|
431
|
-
|
|
432
|
-
sleep tick
|
|
425
|
+
::Wurk::Api::Serializers.limiter_row(name, ::Wurk::Web::Enterprise::Limits.metadata(name))
|
|
433
426
|
end
|
|
434
|
-
rescue ::IOError, ::ActionController::Live::ClientDisconnected
|
|
435
|
-
# client closed; nothing to clean up.
|
|
436
|
-
ensure
|
|
437
|
-
sse.close
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
def monotime
|
|
441
|
-
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
def stream_tick_payload
|
|
445
|
-
::Wurk::Api::Serializers.stats_payload(::Wurk::Stats.new).merge(at: ::Time.now.to_f)
|
|
446
427
|
end
|
|
447
428
|
|
|
448
429
|
def render_cron_action(success)
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'wurk/web'
|
|
4
|
+
|
|
3
5
|
module Wurk
|
|
4
6
|
class ApplicationController < ::ActionController::Base
|
|
5
7
|
protect_from_forgery with: :exception
|
|
8
|
+
|
|
9
|
+
# Route every dashboard/API/SSE Redis read through the dedicated web pool
|
|
10
|
+
# (disjoint from the worker pool, #101) so dashboard load can't exhaust the
|
|
11
|
+
# connections a co-located worker needs. Wraps the whole action — including
|
|
12
|
+
# the SSE stream, which ActionController::Live runs (filters and all) in a
|
|
13
|
+
# spawned thread, so the thread-local set here still covers each tick's
|
|
14
|
+
# checkout.
|
|
15
|
+
around_action :scope_web_redis_pool
|
|
16
|
+
|
|
17
|
+
# A blip/outage surfacing here is the *same* condition RedisPool already
|
|
18
|
+
# retried and gave up on (its `on_error` hook already fired — #101).
|
|
19
|
+
# Report it to the SPA as a structured, retryable 503 instead of letting
|
|
20
|
+
# it fall through to Rails' generic 500 error page, which would leak a
|
|
21
|
+
# backtrace and give the client nothing to branch on.
|
|
22
|
+
rescue_from(*::Wurk::Configuration::REDIS_ERROR_CLASSES) { |ex| render_redis_unavailable(ex) }
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def scope_web_redis_pool(&) = ::Wurk::Web::PoolScope.scope(&)
|
|
27
|
+
|
|
28
|
+
def render_redis_unavailable(ex)
|
|
29
|
+
logger.warn("wurk web: redis unavailable (#{ex.class}: #{ex.message})")
|
|
30
|
+
render json: { error: 'redis_unavailable' }, status: :service_unavailable
|
|
31
|
+
end
|
|
6
32
|
end
|
|
7
33
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require 'net/http'
|
|
4
5
|
require 'uri'
|
|
5
6
|
|
|
@@ -26,7 +27,7 @@ module Wurk
|
|
|
26
27
|
INDEX_REL_PATH = ['vendor', 'assets', 'dashboard', 'index.html'].freeze
|
|
27
28
|
|
|
28
29
|
def index
|
|
29
|
-
render layout: false, html: spa_html.html_safe
|
|
30
|
+
render layout: false, html: inject_mount_base(spa_html).html_safe
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
private
|
|
@@ -35,6 +36,27 @@ module Wurk
|
|
|
35
36
|
ENV['WURK_VITE_DEV'] == '1' ? fetch_vite_dev_shell : read_built_index
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
# The SPA is mount-agnostic: it reads window.__WURK_BASE__ to build every API
|
|
40
|
+
# URL and its client-router base, so the same precompiled bundle works whether
|
|
41
|
+
# the host mounts the engine at /wurk, /sidekiq, or /admin/jobs. script_name
|
|
42
|
+
# is the mount prefix ("/sidekiq"), or "" at the app root. Injected into every
|
|
43
|
+
# served shell (built and Vite-dev) so a non-/wurk mount needs no rebuild.
|
|
44
|
+
def inject_mount_base(html)
|
|
45
|
+
html.sub('</head>', "#{mount_base_script}</head>")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def mount_base_script
|
|
49
|
+
base = request.script_name.to_s.chomp('/')
|
|
50
|
+
%(<script>window.__WURK_BASE__ = #{js_string(base)};</script>\n )
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# JSON-quote the mount prefix as a literal inside <script>, escaping the
|
|
54
|
+
# sequences that could otherwise break out of the tag. The mount is
|
|
55
|
+
# host-configured, not request input, but the injection stays safe regardless.
|
|
56
|
+
def js_string(str)
|
|
57
|
+
str.to_json.gsub(/[<>&]/) { |c| format('\u%04x', c.ord) }
|
|
58
|
+
end
|
|
59
|
+
|
|
38
60
|
def fetch_vite_dev_shell
|
|
39
61
|
uri = ::URI.parse(VITE_DEV_URL)
|
|
40
62
|
rewrite_dev_asset_urls(::Net::HTTP.get(uri))
|
|
@@ -12,11 +12,9 @@ module Wurk
|
|
|
12
12
|
# client-side route (no extension registered under that name — e.g. a browser
|
|
13
13
|
# refresh on /wurk/ext/<tab>/) falls through to the SPA shell instead of 404.
|
|
14
14
|
class ExtensionsController < DashboardController
|
|
15
|
-
# Extension forms carry no Rails CSRF token
|
|
16
|
-
# CSRF
|
|
17
|
-
|
|
18
|
-
skip_forgery_protection
|
|
19
|
-
before_action :verify_same_origin!, unless: -> { request.get? || request.head? }
|
|
15
|
+
# Extension forms carry no Rails CSRF token — SameOriginGuard supplies
|
|
16
|
+
# Sidekiq's same-origin CSRF defense (spec §25.1) in its place.
|
|
17
|
+
include SameOriginGuard
|
|
20
18
|
|
|
21
19
|
def show
|
|
22
20
|
result = Web::Extension::Renderer.call(
|
|
@@ -45,12 +43,5 @@ module Wurk
|
|
|
45
43
|
# Sidekiq::Web rendering its extensions — not user input.
|
|
46
44
|
render html: body.html_safe, layout: false, status: status
|
|
47
45
|
end
|
|
48
|
-
|
|
49
|
-
# Spec §25.1: unsafe methods require `Sec-Fetch-Site: same-origin` — a
|
|
50
|
-
# missing header is denied too, like stock Sidekiq (a non-browser client
|
|
51
|
-
# must spoof the header deliberately; a cookie-carrying browser can't).
|
|
52
|
-
def verify_same_origin!
|
|
53
|
-
head :forbidden unless request.headers['Sec-Fetch-Site'] == 'same-origin'
|
|
54
|
-
end
|
|
55
46
|
end
|
|
56
47
|
end
|
|
@@ -26,6 +26,11 @@ module Wurk
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def show
|
|
29
|
+
# GET-shaped for Sidekiq parity, but uploading the profile to the public
|
|
30
|
+
# Firefox profiler store is a side effect — read-only deploys (e.g. the
|
|
31
|
+
# public demo) must not let any visitor exfiltrate profiling data.
|
|
32
|
+
return head(:forbidden) if ::Wurk::Web.config.read_only?
|
|
33
|
+
|
|
29
34
|
blob = profile_blob(params[:key])
|
|
30
35
|
return head(:not_found) unless blob
|
|
31
36
|
|
|
@@ -38,7 +43,7 @@ module Wurk
|
|
|
38
43
|
private
|
|
39
44
|
|
|
40
45
|
def profile_blob(key)
|
|
41
|
-
Wurk.
|
|
46
|
+
::Wurk::ProfileRecord.data_for(key)
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
# POSTs the gzipped profile to the Firefox profiler's compressed-store.
|
|
@@ -26,6 +26,8 @@ module Wurk
|
|
|
26
26
|
end
|
|
27
27
|
live, _died, first_death = Array(result).map(&:to_i)
|
|
28
28
|
|
|
29
|
+
restamp_ttls(bid)
|
|
30
|
+
|
|
29
31
|
Wurk::Batch::Callbacks.fire_death(bid) if first_death == 1
|
|
30
32
|
return unless live.zero?
|
|
31
33
|
|
|
@@ -35,6 +37,17 @@ module Wurk
|
|
|
35
37
|
# suppressed regardless — the death above set the durable death flag.
|
|
36
38
|
Wurk::Batch::Callbacks.maybe_fire(bid, pending: Wurk::Batch::Callbacks.pending_for(bid), live: 0)
|
|
37
39
|
end
|
|
40
|
+
|
|
41
|
+
# A stale job can die AFTER its batch keys expired; the ack writes
|
|
42
|
+
# recreate them with no TTL — permanent key leakage. EXPIRE NX stamps
|
|
43
|
+
# only keys that lost their TTL, leaving live batches' clocks alone.
|
|
44
|
+
def self.restamp_ttls(bid)
|
|
45
|
+
Wurk.redis do |conn|
|
|
46
|
+
conn.pipelined do |pipe|
|
|
47
|
+
Batch.keys_for(bid).each { |key| pipe.call('EXPIRE', key, Batch::DEFAULT_EXPIRY_SECONDS, 'NX') }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
38
51
|
end
|
|
39
52
|
end
|
|
40
53
|
end
|