wurk 1.8.0 → 1.9.0

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -9
  3. data/app/controllers/wurk/api_controller.rb +49 -10
  4. data/config/routes.rb +20 -12
  5. data/lib/active_job/queue_adapters/wurk_adapter.rb +8 -2
  6. data/lib/sidekiq/rails.rb +5 -0
  7. data/lib/wurk/api/read_only.rb +1 -1
  8. data/lib/wurk/batch/server_middleware.rb +27 -0
  9. data/lib/wurk/capsule.rb +57 -2
  10. data/lib/wurk/cli.rb +4 -2
  11. data/lib/wurk/client.rb +16 -11
  12. data/lib/wurk/compat.rb +12 -2
  13. data/lib/wurk/configuration.rb +86 -7
  14. data/lib/wurk/encryption.rb +47 -8
  15. data/lib/wurk/fetcher/private_list_key.rb +19 -2
  16. data/lib/wurk/fetcher/reaper.rb +36 -12
  17. data/lib/wurk/fetcher/unparseable_keys.rb +22 -7
  18. data/lib/wurk/fetcher.rb +6 -1
  19. data/lib/wurk/health.rb +5 -1
  20. data/lib/wurk/job_logger.rb +1 -1
  21. data/lib/wurk/job_util.rb +4 -1
  22. data/lib/wurk/launcher.rb +4 -3
  23. data/lib/wurk/lua.rb +1 -14
  24. data/lib/wurk/metrics/dashboard_series.rb +21 -2
  25. data/lib/wurk/metrics/history.rb +13 -5
  26. data/lib/wurk/metrics/query.rb +9 -2
  27. data/lib/wurk/metrics/statsd.rb +8 -0
  28. data/lib/wurk/middleware/chain.rb +85 -13
  29. data/lib/wurk/pool_checkout.rb +1 -1
  30. data/lib/wurk/queue_slot.rb +19 -9
  31. data/lib/wurk/rails_boot.rb +17 -6
  32. data/lib/wurk/railtie.rb +7 -3
  33. data/lib/wurk/redis_connection.rb +4 -2
  34. data/lib/wurk/unique.rb +11 -8
  35. data/lib/wurk/version.rb +1 -1
  36. data/lib/wurk/web/config.rb +42 -9
  37. data/lib/wurk/web/enterprise.rb +4 -3
  38. data/lib/wurk/web/extension.rb +74 -39
  39. data/lib/wurk.rb +5 -2
  40. data/vendor/assets/dashboard/assets/{ArgsValue-DsPb_KZ-.js → ArgsValue-DDqX6XWH.js} +1 -1
  41. data/vendor/assets/dashboard/assets/{BatchDetail-BCIXJOpZ.js → BatchDetail-lIytcn2C.js} +1 -1
  42. data/vendor/assets/dashboard/assets/{Batches-IjaQfXW6.js → Batches-BEumt-1O.js} +1 -1
  43. data/vendor/assets/dashboard/assets/{Busy-CaQrf1Ve.js → Busy-Vk9HmZcN.js} +1 -1
  44. data/vendor/assets/dashboard/assets/{Cron-B18EHn_Y.js → Cron-CWcM-Cfr.js} +1 -1
  45. data/vendor/assets/dashboard/assets/{Dashboard-DdT5ZaUg.js → Dashboard-Bgv1kusv.js} +1 -1
  46. data/vendor/assets/dashboard/assets/{Dead-DWIy-Mu8.js → Dead-Di5Rct2H.js} +1 -1
  47. data/vendor/assets/dashboard/assets/Extension-LZY5-imc.js +1 -0
  48. data/vendor/assets/dashboard/assets/FilterBox-BDo-7IAy.js +2 -0
  49. data/vendor/assets/dashboard/assets/{FlowDetail-DnexL72n.js → FlowDetail-CehwRLTt.js} +1 -1
  50. data/vendor/assets/dashboard/assets/{FlowState-Cr4aX6-d.js → FlowState-glK2Tnf8.js} +1 -1
  51. data/vendor/assets/dashboard/assets/{Flows-DO6EimbZ.js → Flows-cgy-Fsbp.js} +1 -1
  52. data/vendor/assets/dashboard/assets/{JobDetailModal-aPjgq-U9.js → JobDetailModal-Cuk3jruU.js} +1 -1
  53. data/vendor/assets/dashboard/assets/{Limiters-DxpwWCNe.js → Limiters-Ci8G2yZG.js} +1 -1
  54. data/vendor/assets/dashboard/assets/Metrics-CQFUeZTR.js +1 -0
  55. data/vendor/assets/dashboard/assets/{NotFound-BfP9uAV9.js → NotFound-CYjhuDsI.js} +1 -1
  56. data/vendor/assets/dashboard/assets/{PageHeader-DbC97afG.js → PageHeader-DjIAFTyX.js} +1 -1
  57. data/vendor/assets/dashboard/assets/{Profiles-BcaHreMe.js → Profiles-DTowqIRg.js} +1 -1
  58. data/vendor/assets/dashboard/assets/{Queues-ZlBc5tyD.js → Queues-DUGRPjME.js} +1 -1
  59. data/vendor/assets/dashboard/assets/{Retries-Bg3nkeiL.js → Retries-SJ3BaKC0.js} +1 -1
  60. data/vendor/assets/dashboard/assets/{Scheduled-BrMZPZGl.js → Scheduled-DxSRpmFZ.js} +1 -1
  61. data/vendor/assets/dashboard/assets/{Search-B5X5pGht.js → Search-bBbi5JNK.js} +1 -1
  62. data/vendor/assets/dashboard/assets/{charts-uDyf4PN0.js → charts-Bg2YBcaD.js} +1 -1
  63. data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +1 -0
  64. data/vendor/assets/dashboard/assets/index-BOTVSP9M.js +141 -0
  65. data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +1 -0
  66. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-O3-5rMk3.js → useResetPageOnEmpty-BRPNyV2M.js} +1 -1
  67. data/vendor/assets/dashboard/assets/{useSort-xfnwjQAu.js → useSort-B-BLJik7.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{utils-BIKHwNKB.js → utils-CGyrKpTh.js} +1 -1
  69. data/vendor/assets/dashboard/index.html +3 -3
  70. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  71. metadata +30 -44
  72. data/vendor/assets/dashboard/assets/Extension-BjBUWhHf.js +0 -1
  73. data/vendor/assets/dashboard/assets/FilterBox-DwH3PXXt.js +0 -1
  74. data/vendor/assets/dashboard/assets/Metrics-C3JL1GIH.js +0 -1
  75. data/vendor/assets/dashboard/assets/i18n-CJeYfeVt.js +0 -1
  76. data/vendor/assets/dashboard/assets/index-B36QCAf-.css +0 -1
  77. data/vendor/assets/dashboard/assets/index-DQujgZgH.js +0 -141
@@ -164,6 +164,14 @@ module Wurk
164
164
  # into a JobRetry::Skip, and a job cut by its deadline passes through
165
165
  # before Middleware::Expiry books it `expired` — both sit outside this
166
166
  # middleware, and without this arm either one emits `jobs.failure`.
167
+ # Their registrations are
168
+ # `Wurk.configuration.server_middleware.prepend(Wurk::Middleware::InterruptHandler)`
169
+ # (lib/wurk/middleware/interrupt_handler.rb:50) and
170
+ # `Wurk.configuration.server_middleware.add(Wurk::Middleware::Expiry)`
171
+ # (lib/wurk/middleware/expiry.rb:111); this middleware is added at
172
+ # `Wurk.configuration.server_middleware.add(Wurk::Metrics::Statsd)`
173
+ # (lib/wurk.rb:351), after Limiter's add at lib/wurk.rb:335 and before
174
+ # History's add at lib/wurk.rb:358.
167
175
  # Pro's statsd emitter is closed source, so the oracle is the free
168
176
  # ExecutionTracker plus the rule that the two Wurk emitters must never
169
177
  # classify one event differently. Signed off in
@@ -7,7 +7,13 @@ module Wurk
7
7
  # pushes to index 0, `insert_before`/`insert_after` anchor relative to an
8
8
  # existing entry, `invoke` walks the chain handing the inner block to each.
9
9
  #
10
- # Entries store klass + ctor args, not the live object: every job gets a
10
+ # `add` has one opt-in exception, declared per middleware class: a class
11
+ # may name the classes it must stay OUTSIDE of in a class-level `outer_of`,
12
+ # and `add` then places it before the first of those anchors instead of at
13
+ # the tail. A class that declares no anchor keeps the plain append.
14
+ #
15
+ # Entries store klass + ctor args + the config they were registered with,
16
+ # not the live object: every job gets a
11
17
  # fresh instance per entry (`invoke` builds each as it reaches it, `retrieve`
12
18
  # builds them all up front). This keeps middleware thread-safe by
13
19
  # construction and matches Sidekiq's lifecycle.
@@ -19,20 +25,28 @@ module Wurk
19
25
  attr_reader :entries
20
26
  attr_accessor :config
21
27
 
22
- def initialize(config = nil)
28
+ def initialize(config = nil, on_registration: nil)
23
29
  @config = config
30
+ @on_registration = on_registration
24
31
  @entries = []
25
32
  yield self if block_given?
26
33
  end
27
34
 
28
35
  def each(&) = @entries.each(&)
29
36
 
30
- # Bind a duplicate of this chain to `capsule`. Capsules share the parent
31
- # config's entries by reference initially but mutate independently after
32
- # the first `add`/`remove`/etc. (Array#dup on `@entries`).
37
+ # Bind a duplicate of this chain to `capsule`. Entries carry their
38
+ # config, so each is duplicated and only the duplicate rebound: the
39
+ # capsule's middleware resolve the capsule while the parent's entries
40
+ # keep the parent config. Array#dup on `@entries` keeps the two chains
41
+ # independent after the first `add`/`remove`/etc.
33
42
  def copy_for(capsule)
34
43
  copy = dup
35
44
  copy.instance_variable_set(:@config, capsule)
45
+ copy.entries.map! do |entry|
46
+ rebound = entry.dup
47
+ rebound.config = capsule
48
+ rebound
49
+ end
36
50
  copy
37
51
  end
38
52
 
@@ -40,26 +54,65 @@ module Wurk
40
54
  @entries.delete_if { |entry| entry.klass == klass }
41
55
  end
42
56
 
57
+ # Appends the middleware — the documented Sidekiq behaviour (spec
58
+ # §10.1): any existing entry for `klass` is removed and the new one
59
+ # lands at the tail (innermost).
60
+ #
61
+ # One opt-in exception, declared per class: a middleware may name the
62
+ # classes it must stay OUTSIDE of in a class-level `outer_of`. When one
63
+ # of those anchors is registered, the entry is placed before the first
64
+ # of them instead of at the tail — so re-registering such a middleware
65
+ # cannot silently move it inside the middleware it depends on being
66
+ # outside of. A class that declares no anchor keeps the plain append.
67
+ #
68
+ # An entry already registered before the anchor keeps its slot (only
69
+ # its ctor args are refreshed): moving it would be a relocation the
70
+ # caller did not ask for.
43
71
  def add(klass, *)
72
+ @on_registration&.call(klass)
73
+ entry = make_entry(klass, *)
74
+ anchors = anchor_classes(klass)
75
+ existing = @entries.index { |e| e.klass == klass }
76
+ anchor = @entries.index { |e| anchors.include?(e.klass) }
77
+
78
+ # No anchor registered: remove the old entry, append at the tail.
79
+ if anchor.nil?
80
+ remove(klass)
81
+ @entries << entry
82
+ return @entries
83
+ end
84
+
85
+ # Already outside the anchor: keep the slot, refresh the entry.
86
+ if existing && existing < anchor
87
+ @entries[existing] = entry
88
+ return @entries
89
+ end
90
+
91
+ # Registered inside the anchor (or not registered at all): move it
92
+ # out, to just before the first anchor.
44
93
  remove(klass)
45
- @entries << Entry.new(klass, *)
94
+ @entries.insert(anchor, entry)
95
+ @entries
46
96
  end
47
97
 
48
98
  def prepend(klass, *)
99
+ @on_registration&.call(klass)
49
100
  remove(klass)
50
- @entries.unshift(Entry.new(klass, *))
101
+ @entries.unshift(make_entry(klass, *))
51
102
  end
52
103
 
53
104
  def insert_before(oldklass, newklass, *)
105
+ @on_registration&.call(newklass)
54
106
  i = @entries.index { |entry| entry.klass == newklass }
55
- new_entry = i.nil? ? Entry.new(newklass, *) : @entries.delete_at(i)
107
+ new_entry = i.nil? ? make_entry(newklass, *) : @entries.delete_at(i)
56
108
  i = @entries.index { |entry| entry.klass == oldklass } || 0
57
109
  @entries.insert(i, new_entry)
58
110
  end
59
111
 
60
112
  def insert_after(oldklass, newklass, *)
113
+ @on_registration&.call(newklass)
61
114
  i = @entries.index { |entry| entry.klass == newklass }
62
- new_entry = i.nil? ? Entry.new(newklass, *) : @entries.delete_at(i)
115
+ new_entry = i.nil? ? make_entry(newklass, *) : @entries.delete_at(i)
63
116
  i = @entries.index { |entry| entry.klass == oldklass } || (@entries.size - 1)
64
117
  @entries.insert(i + 1, new_entry)
65
118
  end
@@ -74,7 +127,7 @@ module Wurk
74
127
  end
75
128
 
76
129
  def retrieve
77
- map { |entry| entry.make_new(@config) }
130
+ map(&:make_new)
78
131
  end
79
132
 
80
133
  def clear
@@ -101,7 +154,7 @@ module Wurk
101
154
  def traverse(entries, index, args, &block)
102
155
  return yield if index == entries.size
103
156
 
104
- entries[index].make_new(@config).call(*args) do
157
+ entries[index].make_new.call(*args) do
105
158
  traverse(entries, index + 1, args, &block)
106
159
  end
107
160
  end
@@ -111,6 +164,24 @@ module Wurk
111
164
  def initialize_copy(orig)
112
165
  super
113
166
  @entries = orig.entries.dup
167
+ @on_registration = nil
168
+ end
169
+
170
+ # The ordering anchors a middleware class declares, if any: a class-level
171
+ # `outer_of` returning the classes it must stay OUTSIDE of. Empty when
172
+ # the class declares none — the plain Sidekiq append.
173
+ def anchor_classes(klass)
174
+ return [] unless klass.respond_to?(:outer_of)
175
+
176
+ Array(klass.outer_of)
177
+ end
178
+
179
+ # Registration binds the entry to this chain's config/capsule so that
180
+ # `Entry#make_new` can stay zero-argument (spec §10.1).
181
+ def make_entry(klass, *)
182
+ entry = Entry.new(klass, *)
183
+ entry.config = @config
184
+ entry
114
185
  end
115
186
 
116
187
  # Holds the klass + ctor args for a registered middleware. Defers
@@ -127,6 +198,7 @@ module Wurk
127
198
  # a class that does not declare it still gets asked.
128
199
  class Entry
129
200
  attr_reader :klass
201
+ attr_accessor :config
130
202
 
131
203
  def initialize(klass, *args)
132
204
  @klass = klass
@@ -134,9 +206,9 @@ module Wurk
134
206
  @takes_config = klass.public_method_defined?(:config=)
135
207
  end
136
208
 
137
- def make_new(config = nil)
209
+ def make_new
138
210
  instance = @klass.new(*@args)
139
- instance.config = config if config && (@takes_config || instance.respond_to?(:config=))
211
+ instance.config = config if @takes_config || instance.respond_to?(:config=)
140
212
  instance
141
213
  end
142
214
  end
@@ -9,7 +9,7 @@ module Wurk
9
9
  # after a command may already have applied server-side (see RedisPool#with).
10
10
  # The object behind a wrapper is not always ours, though: the Sidekiq surface
11
11
  # Wurk drops into lets a host supply any ConnectionPool-shaped object
12
- # (`Sidekiq::Client#redis_pool=`, `Sidekiq::Web.redis_pool=`, the `pool:`
12
+ # (`Sidekiq::Client#redis_pool=`, the `pool:`
13
13
  # kwarg on Stats::History / Deploy / Leader / Profiler / Metrics::History),
14
14
  # and a bare `def with; yield conn; end` decorator — the shape people reach
15
15
  # for to count or trace round trips — is a legal implementation of it. Handing
@@ -65,15 +65,25 @@ module Wurk
65
65
  # of, and it is why a live holder has to keep saying so — {HELD} is the
66
66
  # ledger the heartbeat refreshes from (see {refresh_in}).
67
67
  #
68
- # A rolling restart is the one case where a queue can briefly run over its
69
- # cap: `Swarm::Restart` boots the replacement before it TERMs the old child,
70
- # so for the length of one drain both generations hold slots. That is
71
- # deliberate and left alone. Fixing it would mean either starving the
72
- # replacement until the old child's last job finished — the deploy stalls
73
- # behind the slowest job on the queue — or handing capacity between processes,
74
- # which needs the counter this ledger exists to avoid. The excess is bounded
75
- # by the old child's in-flight count, lasts at most one `drain_timeout`, and
76
- # is TTL-bounded even if that child is SIGKILLed instead of draining.
68
+ # A rolling restart does not run a queue over its cap. `Swarm::Restart` boots
69
+ # the replacement before it TERMs the old child, but both generations' holds
70
+ # are members of the one ZSET and `fetch_slot.lua` refuses a fresh claim once
71
+ # its ZCARD reaches capacity — so the overlap throttles the replacement. The
72
+ # ledger stays honest through the drain: a quieted child keeps beating
73
+ # (Launcher#quiet deliberately leaves the heartbeat running), so its holds
74
+ # stay refreshed until each job's own release takes it off. The one hold
75
+ # that leaves while its job is still in flight is the drain-deadline
76
+ # hand-back — a drain that overruns its deadline bulk-requeues the payload
77
+ # and raises `Wurk::Shutdown`, and the unwind's `ensure` reaches
78
+ # `UnitOfWork#release_slot` while the job is still unwinding. That is a
79
+ # slot-for-slot hand-back, not an extra admission: the re-run spends the
80
+ # freed capacity on its own fresh admission. A child SIGKILLed instead of
81
+ # draining releases nothing, so its holds stay counted until the TTL
82
+ # reclaims them — a refusal window, at most one `drain_timeout` or
83
+ # {TTL_SECONDS} long, never an over-run. What remains deliberate: booting
84
+ # the replacement warm instead of starving it behind the old child's last
85
+ # job, and never handing capacity between processes — that would need the
86
+ # counter this ledger exists to avoid.
77
87
  module QueueSlot
78
88
  # How long a hold survives with nobody refreshing it. Deliberately the same
79
89
  # as `Heartbeat::TTL_SECONDS` (pinned by a test): a hold is refreshed on the
@@ -18,9 +18,10 @@ module Wurk
18
18
  # Invoked from the `wurk.server_mode` initializer, before config/initializers
19
19
  # load. This Rails process forks the workers, so it IS the server. Enter
20
20
  # server mode now — otherwise the app's `Sidekiq.configure_server` blocks
21
- # gate on `config.server?` (still false) and are silently dropped. A process
22
- # that won't run workers (skip_boot?, or one that refuses to boot under a
23
- # preforking web server) is not a server.
21
+ # gate on `config.server?` (still false) and are dropped, each skip logging
22
+ # one WARN naming the gate (`configure_server` / `server? is false`). A
23
+ # process that won't run workers (skip_boot?, or one that refuses to boot
24
+ # under a preforking web server) is not a server.
24
25
  #
25
26
  # It is a client as well: its web requests enqueue, so the app's
26
27
  # `Sidekiq.configure_client` blocks (client middleware, the client's Redis)
@@ -68,17 +69,27 @@ module Wurk
68
69
  # custom script that requires config/environment — and a denylist can
69
70
  # never name them all: each one forked a full swarm that fetched jobs, and
70
71
  # its at_exit drain then cut those jobs off when the one-off command
71
- # finished. The markers are constants only a running server defines
72
- # (Bundler.require of the puma gem loads `Puma`, never `Puma::Launcher`).
72
+ # finished. The markers are constants only a running server defines.
73
73
  # Any other server (Falcon, Thin, Pitchfork, a bespoke rackup) opts in
74
74
  # with WURK_EMBED=1.
75
75
  def serving?
76
76
  return true if ENV['WURK_EMBED'] == '1'
77
- return true if defined?(::Rails::Server) || defined?(::Puma::Launcher) || defined?(::PhusionPassenger)
77
+ return true if defined?(::Rails::Server) || puma_launcher_loaded? || defined?(::PhusionPassenger)
78
78
 
79
79
  unicorn_server?
80
80
  end
81
81
 
82
+ # Bundler.require of the puma gem defines `Puma` in every process, and
83
+ # puma >= 6 registers `autoload :Launcher` in lib/puma.rb — `defined?`
84
+ # reports an autoload as "constant" without loading it, so the bare check
85
+ # was true in `rails runner` / a console and forked a swarm there. Only a
86
+ # running server has actually loaded the launcher.
87
+ def puma_launcher_loaded?
88
+ return false unless defined?(::Puma::Launcher)
89
+
90
+ !::Puma.autoload?(:Launcher)
91
+ end
92
+
82
93
  # `require 'unicorn'` (a Gemfile entry) defines the constant in every
83
94
  # process, so the program name is what tells the server apart.
84
95
  def unicorn_server?
data/lib/wurk/railtie.rb CHANGED
@@ -18,11 +18,15 @@ module Wurk
18
18
  # @@options, so `Rails.application.config.wurk` reads back the same object.
19
19
  config.wurk = ::ActiveSupport::OrderedOptions.new
20
20
 
21
- # Enter server mode BEFORE config/initializers load — otherwise the app's
22
- # `Sidekiq.configure_server` blocks gate on `config.server?` (still false)
23
- # and are silently dropped. RailsBoot decides whether this process serves.
21
+ # The gemspec ships no .rake files, so the railtie loads Wurk's
22
+ # host-facing rake tasks itself for `bin/rails wurk:import:cron` to exist.
24
23
  rake_tasks { load File.expand_path('rake_tasks.rb', __dir__) }
25
24
 
25
+ # Enter server mode BEFORE config/initializers load — otherwise the app's
26
+ # `Sidekiq.configure_server` blocks gate on `config.server?` (still false)
27
+ # and are dropped, each skip logging one WARN naming the gate
28
+ # (`configure_server` / `server? is false`). RailsBoot decides whether this
29
+ # process serves.
26
30
  initializer 'wurk.server_mode', before: :load_config_initializers do |app|
27
31
  Wurk::RailsBoot.enter_server_mode_if_serving(app)
28
32
  end
@@ -5,11 +5,13 @@ require_relative 'redis_pool'
5
5
  module Wurk
6
6
  # Sidekiq-compatible pool constructor. `Sidekiq::RedisConnection.create(...)`
7
7
  # is the documented way to build a standalone Redis pool (spec §26) — it's the
8
- # default pool for `Sidekiq::Deploy` (§23) and the constructor for per-shard
9
- # pools in multi-shard web mounts (Pro §10.2). Returns a `Wurk::RedisPool`
8
+ # default pool for `Sidekiq::Deploy` (§23). Returns a `Wurk::RedisPool`
10
9
  # (connection_pool-backed, redis-client adapter), which is `.with`-compatible
11
10
  # with everything that expects a Sidekiq pool.
12
11
  #
12
+ # Pro §10.2 pool-bound multi-shard web mounts (`Sidekiq::Pro::Web.with`) are
13
+ # unsupported: `Wurk::Web` has no `.with` factory.
14
+ #
13
15
  # Accepts Sidekiq's option keys (`url`, `size`, `pool_timeout`, `pool_name`,
14
16
  # and `name` — the Sentinel master next to `sentinels:`) plus the socket
15
17
  # knobs (`connect_timeout`/`read_timeout`/`write_timeout`/
data/lib/wurk/unique.rb CHANGED
@@ -98,9 +98,12 @@ module Wurk
98
98
  @enabled == true
99
99
  end
100
100
 
101
- def enable!
101
+ # `config` selects the chains and death handlers, installed once each;
102
+ # the enabled flag remains process-global, including for other configs
103
+ # whose chains already carry the middleware.
104
+ def enable!(config = Wurk.configuration)
102
105
  @enabled = true
103
- register_middleware!
106
+ register_middleware!(config)
104
107
  true
105
108
  end
106
109
 
@@ -188,12 +191,12 @@ module Wurk
188
191
  nil
189
192
  end
190
193
 
191
- def register_middleware!
192
- Wurk.configuration.client_middleware.add(ClientMiddleware) \
193
- unless Wurk.configuration.client_middleware.exists?(ClientMiddleware)
194
- Wurk.configuration.server_middleware.add(ServerMiddleware) \
195
- unless Wurk.configuration.server_middleware.exists?(ServerMiddleware)
196
- handlers = Wurk.configuration.death_handlers
194
+ def register_middleware!(config)
195
+ client_chain = config.client_middleware
196
+ server_chain = config.server_middleware
197
+ client_chain.add(ClientMiddleware) unless client_chain.exists?(ClientMiddleware)
198
+ server_chain.add(ServerMiddleware) unless server_chain.exists?(ServerMiddleware)
199
+ handlers = config.death_handlers
197
200
  handlers << DEATH_HANDLER unless handlers.include?(DEATH_HANDLER)
198
201
  end
199
202
  end
data/lib/wurk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wurk
4
- VERSION = '1.8.0'
4
+ VERSION = '1.9.0'
5
5
  end
@@ -129,7 +129,9 @@ module Wurk
129
129
  # (the default) out of localStorage; this is what it falls back to for a
130
130
  # visitor who has never picked one. Injected ahead of the shell's
131
131
  # pre-paint script so a host default paints with everything else instead
132
- # of flashing the other palette. nil emits nothing.
132
+ # of flashing the other palette. nil emits nothing. Initialization and
133
+ # reset! sample WURK_WEB_DEFAULT_THEME through the validating writer;
134
+ # later explicit assignments (including nil) override that seed.
133
135
  attr_reader :default_theme
134
136
 
135
137
  def default_theme=(value)
@@ -163,7 +165,16 @@ module Wurk
163
165
  # Keyed rather than flat because the negotiated hint is not the last
164
166
  # word — a `?locale=` link or a stored pick outranks it — so the entry
165
167
  # has to be chosen in the browser, against the locale actually rendered.
166
- attr_accessor :translations
168
+ attr_reader :translations
169
+
170
+ def translations=(value)
171
+ raise ArgumentError, "translations must be a Hash (got #{value.class})" unless value.is_a?(::Hash)
172
+
173
+ value.each do |locale, copy|
174
+ validate_translation_hash!(copy, "translations[#{locale.inspect}]")
175
+ end
176
+ @translations = value
177
+ end
167
178
 
168
179
  # Registers a `(env, method, path) -> truthy/falsey` block. Re-calling
169
180
  # overwrites; the spec exposes a single hook, not a chain.
@@ -250,8 +261,8 @@ module Wurk
250
261
  # that wraps the dashboard, in front of the authorization hook, so a
251
262
  # host app can gate the UI with Devise/Warden/Sorcery/Rack::Auth::Basic
252
263
  # without writing its own middleware. `args` and an optional block pass
253
- # straight through to the middleware's `new`. Call before the first
254
- # request (i.e. from an initializer) — the chain is built once.
264
+ # straight through to the middleware's `new`. Each inner app's chain is
265
+ # cached until the middleware configuration changes.
255
266
  def use(middleware, *args, &block)
256
267
  @middlewares << [middleware, args, block]
257
268
  end
@@ -263,13 +274,15 @@ module Wurk
263
274
  # on one evict the other's chain. The snapshot matters because
264
275
  # `middlewares` is the live array (upstream surface): direct mutation
265
276
  # after the first request triggers a rebuild instead of silently serving
266
- # the stale chain. Steady state is one Hash lookup plus an == over a
267
- # handful of entries.
277
+ # the stale chain. Copy triples and positional-argument lists so edits
278
+ # cannot mutate the snapshot too; argument objects and blocks retain
279
+ # their identities (their internal mutations are not tracked). Steady
280
+ # state is one Hash lookup plus an == over a handful of entries.
268
281
  def rack_app(inner)
269
282
  snapshot, app = @rack_apps[inner]
270
283
  return app if app && snapshot == @middlewares
271
284
 
272
- stack = @middlewares.dup
285
+ stack = middleware_snapshot
273
286
  app = ::Rack::Builder.new do
274
287
  stack.each { |middleware, args, block| use(middleware, *args, &block) }
275
288
  run inner
@@ -279,7 +292,7 @@ module Wurk
279
292
  end
280
293
 
281
294
  # Read-only mode. When on, the Authorization middleware blocks every
282
- # non-GET request (retry/kill/requeue/delete/pause/resume/clear) with
295
+ # non-GET request (retry/kill/add_to_queue/delete/pause/unpause/clear) with
283
296
  # 403, and the SPA hides destructive actions via the /api/meta flag.
284
297
  # Defaults from WURK_WEB_READ_ONLY=1 so a viewer-only deploy (e.g. the
285
298
  # public demo) needs no Ruby config.
@@ -296,7 +309,7 @@ module Wurk
296
309
  @authorization = nil
297
310
  @read_only = env_read_only?
298
311
  @read_only_message = nil
299
- @default_theme = nil
312
+ self.default_theme = ENV.fetch('WURK_WEB_DEFAULT_THEME', nil)
300
313
  @max_streams = nil
301
314
  @middlewares = []
302
315
  @rack_apps = {}.compare_by_identity
@@ -316,6 +329,26 @@ module Wurk
316
329
 
317
330
  private
318
331
 
332
+ def middleware_snapshot
333
+ @middlewares.map { |middleware, args, block| [middleware, args.dup, block] }
334
+ end
335
+
336
+ def validate_translation_hash!(copy, path)
337
+ raise ArgumentError, "#{path} must be a Hash (got #{copy.class})" unless copy.is_a?(::Hash)
338
+
339
+ copy.each do |key, value|
340
+ child_path = "#{path}[#{key.inspect}]"
341
+ case value
342
+ when ::Hash
343
+ validate_translation_hash!(value, child_path)
344
+ when ::String
345
+ next
346
+ else
347
+ raise ArgumentError, "#{child_path} must be a String or Hash (got #{value.class})"
348
+ end
349
+ end
350
+ end
351
+
319
352
  def env_read_only?
320
353
  ENV['WURK_WEB_READ_ONLY'] == '1'
321
354
  end
@@ -198,9 +198,10 @@ module Wurk
198
198
  end
199
199
 
200
200
  # Global top-N rollup. Wraps `Query.top_jobs` and keeps the wire
201
- # shape of `[[class_name, {p:, f:, ms:}], ...]`.
202
- def top(minutes: 60, class_filter: nil)
203
- Wurk::Metrics::Query.top_jobs(minutes: minutes, class_filter: class_filter)
201
+ # shape of `[[class_name, {p:, f:, ms:}], ...]`. `hours:` reads the
202
+ # 10-minute buckets instead of the per-minute ones.
203
+ def top(minutes: 60, hours: nil, class_filter: nil)
204
+ Wurk::Metrics::Query.top_jobs(minutes: minutes, hours: hours, class_filter: class_filter)
204
205
  end
205
206
 
206
207
  # Cluster-total time-series for the dashboard charts. `bucket` is