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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -0
  3. data/app/controllers/concerns/wurk/same_origin_guard.rb +40 -0
  4. data/app/controllers/concerns/wurk/sse_streaming.rb +48 -0
  5. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +53 -0
  6. data/app/controllers/wurk/api/pagination.rb +60 -11
  7. data/app/controllers/wurk/api/serializers.rb +5 -1
  8. data/app/controllers/wurk/api_controller.rb +51 -70
  9. data/app/controllers/wurk/application_controller.rb +26 -0
  10. data/app/controllers/wurk/dashboard_controller.rb +23 -1
  11. data/app/controllers/wurk/extensions_controller.rb +3 -12
  12. data/app/controllers/wurk/profiles_controller.rb +6 -1
  13. data/lib/wurk/batch/death_handler.rb +13 -0
  14. data/lib/wurk/batch/server_middleware.rb +9 -5
  15. data/lib/wurk/batch.rb +3 -0
  16. data/lib/wurk/capsule.rb +34 -21
  17. data/lib/wurk/cli.rb +16 -3
  18. data/lib/wurk/client/buffered.rb +30 -10
  19. data/lib/wurk/client.rb +45 -1
  20. data/lib/wurk/component.rb +23 -9
  21. data/lib/wurk/configuration.rb +83 -18
  22. data/lib/wurk/cron.rb +13 -1
  23. data/lib/wurk/dead_set.rb +16 -1
  24. data/lib/wurk/engine.rb +17 -1
  25. data/lib/wurk/fetcher/reliable.rb +46 -13
  26. data/lib/wurk/fetcher.rb +5 -0
  27. data/lib/wurk/health.rb +74 -22
  28. data/lib/wurk/history.rb +4 -21
  29. data/lib/wurk/job_retry.rb +3 -5
  30. data/lib/wurk/launcher.rb +26 -3
  31. data/lib/wurk/limiter/server_middleware.rb +16 -1
  32. data/lib/wurk/limiter.rb +6 -5
  33. data/lib/wurk/lua/loader.rb +11 -5
  34. data/lib/wurk/lua.rb +100 -9
  35. data/lib/wurk/manager.rb +59 -19
  36. data/lib/wurk/metrics/queue_rollup.rb +4 -21
  37. data/lib/wurk/metrics/rollup.rb +4 -21
  38. data/lib/wurk/processor.rb +8 -8
  39. data/lib/wurk/profile_set.rb +21 -6
  40. data/lib/wurk/rails_boot.rb +176 -0
  41. data/lib/wurk/railtie.rb +19 -47
  42. data/lib/wurk/redis_connection.rb +6 -9
  43. data/lib/wurk/redis_pool.rb +148 -28
  44. data/lib/wurk/scheduled.rb +35 -12
  45. data/lib/wurk/swarm/backoff.rb +70 -0
  46. data/lib/wurk/swarm/child_boot.rb +92 -13
  47. data/lib/wurk/swarm/orphan_guard.rb +105 -0
  48. data/lib/wurk/swarm/restart.rb +196 -0
  49. data/lib/wurk/swarm.rb +194 -78
  50. data/lib/wurk/timer_loop.rb +49 -0
  51. data/lib/wurk/version.rb +1 -1
  52. data/lib/wurk/web/extension.rb +4 -1
  53. data/lib/wurk/web/pool_scope.rb +46 -0
  54. data/lib/wurk/web/rack_app.rb +2 -1
  55. data/lib/wurk/web/search.rb +77 -18
  56. data/lib/wurk/web.rb +1 -0
  57. data/lib/wurk/worker/setter.rb +6 -1
  58. data/lib/wurk.rb +6 -2
  59. data/vendor/assets/dashboard/assets/ArgsValue-DYfBiXrJ.js +1 -0
  60. data/vendor/assets/dashboard/assets/BatchDetail-DTZ2HzcD.js +1 -0
  61. data/vendor/assets/dashboard/assets/Batches-BrnXA332.js +1 -0
  62. data/vendor/assets/dashboard/assets/Busy-C57G8Xb3.js +1 -0
  63. data/vendor/assets/dashboard/assets/Cron-DlyH88oo.js +1 -0
  64. data/vendor/assets/dashboard/assets/Dashboard-B887pxWf.js +1 -0
  65. data/vendor/assets/dashboard/assets/Dead-BPA7gs-X.js +1 -0
  66. data/vendor/assets/dashboard/assets/Extension-Bunf6XuU.js +1 -0
  67. data/vendor/assets/dashboard/assets/FilterBox-FCDi4ZCU.js +1 -0
  68. data/vendor/assets/dashboard/assets/JobDetailModal-DuMdKUMm.js +2 -0
  69. data/vendor/assets/dashboard/assets/Limiters-Br0aCPMK.js +1 -0
  70. data/vendor/assets/dashboard/assets/Metrics-DxBmuywH.js +1 -0
  71. data/vendor/assets/dashboard/assets/Modal-t4FI_LaY.js +1 -0
  72. data/vendor/assets/dashboard/assets/PageHeader-CsDvJSOA.js +1 -0
  73. data/vendor/assets/dashboard/assets/Profiles-Bkhoqjlq.js +1 -0
  74. data/vendor/assets/dashboard/assets/Queues-BvhA-vfI.js +1 -0
  75. data/vendor/assets/dashboard/assets/Retries-JEpB-1Yl.js +1 -0
  76. data/vendor/assets/dashboard/assets/Scheduled-DN_FbSwP.js +1 -0
  77. data/vendor/assets/dashboard/assets/Search-DJuK0YCJ.js +1 -0
  78. data/vendor/assets/dashboard/assets/Skeleton-DOYDkzg1.js +1 -0
  79. data/vendor/assets/dashboard/assets/charts-CVK0zAnC.js +1 -0
  80. data/vendor/assets/dashboard/assets/index-BnPX9Ptn.css +1 -0
  81. data/vendor/assets/dashboard/assets/index-CZTcs-pM.js +141 -0
  82. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-CoZU4b3a.js +1 -0
  83. data/vendor/assets/dashboard/assets/useSort-wQcnbdsa.js +1 -0
  84. data/vendor/assets/dashboard/index.html +3 -3
  85. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  86. metadata +55 -26
  87. data/vendor/assets/dashboard/assets/ArgsValue-BUqJa-eG.js +0 -1
  88. data/vendor/assets/dashboard/assets/BatchDetail-C5dkqAzp.js +0 -1
  89. data/vendor/assets/dashboard/assets/Batches-bgkXn8tc.js +0 -1
  90. data/vendor/assets/dashboard/assets/Busy-QSHBFXhy.js +0 -1
  91. data/vendor/assets/dashboard/assets/Cron-CUHppvTA.js +0 -1
  92. data/vendor/assets/dashboard/assets/Dashboard-CzPudckV.js +0 -1
  93. data/vendor/assets/dashboard/assets/Dead-BIq4Nz_i.js +0 -1
  94. data/vendor/assets/dashboard/assets/Extension-CW36i9R1.js +0 -1
  95. data/vendor/assets/dashboard/assets/JobDetailModal-BhNdWSp7.js +0 -2
  96. data/vendor/assets/dashboard/assets/Limiters-CiI_DfUK.js +0 -1
  97. data/vendor/assets/dashboard/assets/Metrics-BzZ8ugms.js +0 -1
  98. data/vendor/assets/dashboard/assets/Modal-DzVfgsSF.js +0 -1
  99. data/vendor/assets/dashboard/assets/PageHeader-Dp3qhX3e.js +0 -1
  100. data/vendor/assets/dashboard/assets/Profiles-BTtIlTdR.js +0 -1
  101. data/vendor/assets/dashboard/assets/Queues-BuXoxQ4W.js +0 -1
  102. data/vendor/assets/dashboard/assets/Retries-D4HAPaOQ.js +0 -1
  103. data/vendor/assets/dashboard/assets/Scheduled-6NCZYVJh.js +0 -1
  104. data/vendor/assets/dashboard/assets/Search-JaB_-52c.js +0 -1
  105. data/vendor/assets/dashboard/assets/charts-6uvCyY0x.js +0 -1
  106. data/vendor/assets/dashboard/assets/i18n-gIeA5VLo.js +0 -1
  107. data/vendor/assets/dashboard/assets/index-BDG9tvBA.css +0 -1
  108. data/vendor/assets/dashboard/assets/index-DWfWAGBc.js +0 -141
  109. data/vendor/assets/dashboard/assets/useJobSetActions-DTDaAoWr.js +0 -1
  110. data/vendor/assets/dashboard/assets/usePageParam-BcAvRko-.js +0 -1
  111. 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: d6d1517377dd46c320f3e03fc705dd4877530c948a51c525a375aa6caacfe964
4
- data.tar.gz: aa4e80a91da88be21978b22a776f79bd5fb89d6c793cd7826f4e9292041071ea
3
+ metadata.gz: 8911da9e550c5d8078c4293ebb69f3b8620a4285692f6e3a6b37d41b711c9219
4
+ data.tar.gz: 9cc0cf226409e7fb5f9ffce6749b2a7328324e9319350d7752c8bdf8d33e7437
5
5
  SHA512:
6
- metadata.gz: 35c6371f7561bbd62340002b9bf9850abaf79745b8e330ce7a6fe06cd265cc564ab65a47d279e7b0cfe0f9f058758f0e31af0d3ed1ec5e1ee449fbdf9a7e5506
7
- data.tar.gz: 7694620688b95769ed0713e827cd1c9c6b78372d0e735eee4c3244835ccc19c7a0be1be18bfc0d42bcc364e449176d423455242a9f7cfe6522b578b75045f3f4
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: [params[:page].to_i, 0].max,
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
- def slice(enumerable, page)
43
- results = []
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
- idx = 0
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
- if idx >= offset
48
- payload = yield(member)
49
- if payload && match?(payload, page[:substr])
50
- results << payload
51
- break if results.size >= page[:count]
52
- end
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
- idx += 1
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
- def process_row(process)
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 = 600.0
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. LREM matches exact bytes, so we
79
- # locate the record (Queue#find_job) and let it delete its own value.
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
- record = ::Wurk::Queue.new(params[:name].to_s).find_job(params[:jid].to_s)
82
- return render(json: { error: 'unknown job' }, status: :not_found) unless record
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: record.delete }
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
- render json: ::Wurk::ProcessSet.new.map { |p| ::Wurk::Api::Serializers.process_row(p) }
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
- hits = ::Wurk::Web::Search.new(substr, kinds: parse_search_kinds(params), limit: parse_search_limit(params)).to_a
271
- render json: { substr: substr, total: hits.size, hits: hits }
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
- stream_headers!
289
- clamp = ::Wurk::Api::Pagination.method(:clamp_float)
290
- tick = clamp.call(params[:tick], 0.0, STREAM_TICK_SECONDS, STREAM_TICK_SECONDS)
291
- max_dur = clamp.call(params[:max_duration], 0.0, STREAM_MAX_DURATION, STREAM_MAX_DURATION)
292
- sse = ::ActionController::Live::SSE.new(response.stream, retry: (STREAM_TICK_SECONDS * 1000).to_i)
293
- drive_stream(sse, tick, max_dur)
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 until empty. Used for
386
- # scheduled "add to queue all", where each call removes the entry and would
387
- # otherwise shift the paged iterator's indices mid-scan.
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
- count = 0
390
- until set.size.zero?
391
- set.each do |entry|
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, limiter_meta(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. Parity with Sidekiq's own
16
- # CSRF model instead (spec §25.1): unsafe methods must be same-origin per
17
- # Sec-Fetch-Site; cross-site requests are denied. GETs are unaffected.
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.redis { |conn| conn.call('HGET', key, 'data') }
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