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
@@ -163,7 +163,7 @@ module Wurk
163
163
  @capsules = {}
164
164
  @directory = {}
165
165
  @client_chain = Middleware::Chain.new
166
- @server_chain = Middleware::Chain.new
166
+ @server_chain = Middleware::Chain.new(on_registration: method(:warn_on_late_server_registration))
167
167
  @redis_config = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') }
168
168
  @web_redis_pool = nil
169
169
  @logger = nil
@@ -705,7 +705,26 @@ module Wurk
705
705
  # (200 only when Redis is reachable AND heartbeat fired within
706
706
  # `ready_window` seconds).
707
707
  #
708
- # Off by default — call this in a `configure_server` block to enable.
708
+ # Off by default. Put this in Rails `config/initializers/wurk.rb` loaded
709
+ # by the worker boot, or in the Ruby boot file loaded via `-r` by `wurk`,
710
+ # `wurkswarm`, or `sidekiqswarm`:
711
+ #
712
+ # Wurk.configure_server do |config|
713
+ # config.health_check(port: 7433)
714
+ # end
715
+ #
716
+ # `configure_server` executes its block only when the current process is
717
+ # already in server mode. Configuration is process-local: configuring a
718
+ # separate web/client process cannot enable the worker's probes.
719
+ #
720
+ # For explicit embedding, configure the embedded worker directly before
721
+ # calling `run`; `configure_embed` does not enter server mode:
722
+ #
723
+ # embedded = Wurk.configure_embed do |config|
724
+ # config.health_check(port: 7433)
725
+ # end
726
+ # embedded.run
727
+ #
709
728
  # Spec: docs/target/sidekiq-ent.md §7.1.2.
710
729
  #
711
730
  # `metrics:` serves the Prometheus scrape on the same listener (on by
@@ -773,15 +792,33 @@ module Wurk
773
792
 
774
793
  # --- Configure blocks (Sidekiq.configure_server / _client) -----------
775
794
 
776
- def configure_server(&block)
777
- yield self if block && server?
795
+ # The gate is evaluated per call: a skipped block is never yielded and
796
+ # never replayed, and an allowed one yields the configuration and returns
797
+ # the block's value. A skip reports itself once per gate per instance —
798
+ # the flags below decide only whether the skip is reported again, and
799
+ # they live outside @options so a frozen configuration can still warn.
800
+ def configure_server
801
+ return unless block_given?
802
+ return yield self if server?
803
+ return if @configure_server_skip_warned
804
+
805
+ @configure_server_skip_warned = true
806
+ logger.warn { 'configure_server block skipped: server? is false' }
807
+ nil
778
808
  end
779
809
 
780
810
  # A web process that also boots the swarm (Wurk::RailsBoot) is flagged
781
811
  # server, yet every enqueue it makes from a request is client work, so
782
- # `client_in_server` lets its configure_client blocks run too.
783
- def configure_client(&block)
784
- yield self if block && (!server? || @options[:client_in_server])
812
+ # `client_in_server` lets its configure_client blocks run too. Same
813
+ # one-warning-per-gate contract as configure_server.
814
+ def configure_client
815
+ return unless block_given?
816
+ return yield self if !server? || @options[:client_in_server]
817
+ return if @configure_client_skip_warned
818
+
819
+ @configure_client_skip_warned = true
820
+ logger.warn { 'configure_client block skipped: server? is true and client_in_server is false or nil' }
821
+ nil
785
822
  end
786
823
 
787
824
  def server?
@@ -854,6 +891,7 @@ module Wurk
854
891
  prepare_for_fork!
855
892
  @capsules.each_value(&:freeze)
856
893
  @capsules.freeze
894
+ warn_on_missing_server_middleware
857
895
  warn_on_small_db_pool
858
896
  self
859
897
  end
@@ -868,6 +906,47 @@ module Wurk
868
906
 
869
907
  private
870
908
 
909
+ def warn_on_late_server_registration(klass)
910
+ affected = @capsules.each_value.filter_map do |capsule|
911
+ capsule.name if capsule.prepared? && capsule.server_middleware_missing?(klass)
912
+ end
913
+ return if affected.empty?
914
+
915
+ logger.warn do
916
+ "Global server middleware #{klass} registered after preparation for capsules: #{affected.join(', ')}; " \
917
+ 'existing worker snapshots remain unchanged. Register middleware before worker boot, then restart workers. ' \
918
+ 'This diagnostic only inspects capsules in this process.'
919
+ end
920
+ end
921
+
922
+ # Registrations run against the singleton when wurk is loaded, not when a
923
+ # host constructs a Configuration. Resolve classes here to avoid making
924
+ # configuration.rb depend on middleware load order or mutable global chains.
925
+ def warn_on_missing_server_middleware
926
+ built_ins = %w[
927
+ Middleware::InterruptHandler Middleware::Expiry Middleware::Timeout
928
+ Middleware::Status Middleware::I18n::Server Batch::ServerMiddleware
929
+ Limiter::ServerMiddleware Metrics::Statsd Metrics::History
930
+ ].filter_map { |name| loaded_middleware_class(name) }
931
+ return if @server_chain.entries.any? { |entry| built_ins.include?(entry.klass) }
932
+
933
+ logger.warn do
934
+ 'Wurk configuration has no built-in server middleware; batch callbacks, limiter handling, ' \
935
+ 'job expiry, timeouts, status tracking, locale propagation, interrupt handling and metrics ' \
936
+ 'may be unavailable. Fresh Configuration.new instances do not inherit load-time registrations. ' \
937
+ 'Use Wurk.default_configuration or explicitly install the middleware your jobs need. ' \
938
+ 'Custom-only chains are allowed; this warning does not change the chain.'
939
+ end
940
+ end
941
+
942
+ def loaded_middleware_class(name)
943
+ name.split('::').reduce(Wurk) do |namespace, part|
944
+ break unless namespace.const_defined?(part, false)
945
+
946
+ namespace.const_get(part, false)
947
+ end
948
+ end
949
+
871
950
  def validate_constructor_options!
872
951
  @options[:global_concurrency] = normalize_global_concurrency(@options[:global_concurrency])
873
952
  RedisPool.validate_idle_timeout(@options[:redis_idle_timeout])
@@ -70,6 +70,13 @@ module Wurk
70
70
  class Error < StandardError; end
71
71
  class KeyMissingError < Error; end
72
72
 
73
+ # Raised when crypto is enabled too late to reach the workers. A capsule
74
+ # that already holds the server middleware chain its workers run can never
75
+ # receive ServerMiddleware again, so enabling now would seal arguments the
76
+ # workers cannot open — `perform` would receive the raw envelope. The
77
+ # enablement is refused whole: nothing is enabled, nothing is sealed.
78
+ class UnsafeEnablementError < Error; end
79
+
73
80
  # Marker for a terminal, non-retryable decryption failure (missing or
74
81
  # rotated-away key, tampered ciphertext). The server middleware raises
75
82
  # JobRetry::Skip after routing the job to the dead set, so this class
@@ -88,16 +95,20 @@ module Wurk
88
95
  # keys for any older in-flight versions so they decrypt.
89
96
  #
90
97
  # Idempotent: re-calling rebinds the resolver and rebuilds the cache.
91
- # Middleware is installed at most once per chain.
92
- def enable(active_version:, &resolver)
98
+ # Middleware is installed at most once per chain. `config` selects the
99
+ # chains and capsules to check, not a separate crypto context: enabled
100
+ # state, active version, resolver and key cache remain process-global.
101
+ def enable(config = Wurk.configuration, active_version:, &resolver)
93
102
  raise ArgumentError, 'active_version is required' unless active_version
94
103
  raise ArgumentError, 'block returning the key bytes is required' unless resolver
95
104
 
105
+ refuse_unreachable_enablement!(config)
106
+
96
107
  @active_version = Integer(active_version)
97
108
  @resolver = resolver
98
109
  @key_cache = {}
99
110
  @enabled = true
100
- register_middleware!
111
+ register_middleware!(config)
101
112
  true
102
113
  end
103
114
 
@@ -223,6 +234,34 @@ module Wurk
223
234
 
224
235
  private
225
236
 
237
+ # The client middleware chain is live: a middleware registered on the
238
+ # Configuration is picked up by the very next push. The server chain is
239
+ # a snapshot — a capsule that has been prepared to run jobs holds the
240
+ # copy its workers run, and a middleware registered after that copy was
241
+ # taken can never reach it. Enabling crypto then would seal arguments
242
+ # the workers cannot open, so the enablement is refused before any
243
+ # state changes: `enabled?` stays false and the client middleware keeps
244
+ # the last argument in cleartext.
245
+ #
246
+ # The guard is per-process: it sees this process's capsules only. A
247
+ # producer in another process that enabled crypto while some worker
248
+ # never enabled it at all is a different misconfiguration — enable
249
+ # everywhere, not just on workers.
250
+ def refuse_unreachable_enablement!(config)
251
+ unreachable = config.capsules.values.select do |capsule|
252
+ capsule.prepared? && capsule.server_middleware_missing?(ServerMiddleware)
253
+ end
254
+ return if unreachable.empty?
255
+
256
+ raise UnsafeEnablementError, <<~MSG
257
+ Wurk::Encryption.enable was called too late to reach the workers: the #{unreachable.map(&:name).join(', ')} \
258
+ capsule(s) already hold the server middleware chain their workers run, and that chain cannot \
259
+ receive Wurk::Encryption::ServerMiddleware — the workers would execute encrypted jobs without the \
260
+ decryptor and hand `perform` the raw envelope. Enable crypto before the worker boots — in a \
261
+ config/initializers file, not an after_initialize hook — then restart the workers.
262
+ MSG
263
+ end
264
+
226
265
  def build_decrypt_cipher(envelope, key)
227
266
  cipher = ::OpenSSL::Cipher.new(CIPHER_NAME).decrypt
228
267
  cipher.key = key
@@ -252,11 +291,11 @@ module Wurk
252
291
  bytes
253
292
  end
254
293
 
255
- def register_middleware!
256
- Wurk.configuration.client_middleware.add(ClientMiddleware) \
257
- unless Wurk.configuration.client_middleware.exists?(ClientMiddleware)
258
- Wurk.configuration.server_middleware.add(ServerMiddleware) \
259
- unless Wurk.configuration.server_middleware.exists?(ServerMiddleware)
294
+ def register_middleware!(config)
295
+ client_chain = config.client_middleware
296
+ server_chain = config.server_middleware
297
+ client_chain.add(ClientMiddleware) unless client_chain.exists?(ClientMiddleware)
298
+ server_chain.add(ServerMiddleware) unless server_chain.exists?(ServerMiddleware)
260
299
  end
261
300
  end
262
301
 
@@ -9,6 +9,9 @@ module Wurk
9
9
  # Reliable.private_queue_name) for the Reaper's liveness check. Pure string
10
10
  # work, no Redis.
11
11
  module PrivateListKey
12
+ # The nonce shape Component::PROCESS_NONCE writes (`SecureRandom.hex(6)`).
13
+ NONCE_PATTERN = /\A[0-9a-f]{12}\z/
14
+
12
15
  module_function
13
16
 
14
17
  # `queue:<public>|<host>|<pid>|<nonce>|<idx>` → [public_q, host, pid,
@@ -19,7 +22,11 @@ module Wurk
19
22
  # preference order and the first one leaving a real public queue behind
20
23
  # wins. The narrow reading is what saves a pre-nonce key from an all-digit
21
24
  # host (`queue:q|123456789012|<pid>|<idx>` — a bare Docker hostname is 12
22
- # hex chars): read wide, its host segment eats the whole queue name.
25
+ # hex chars): read wide, its host segment either eats the whole queue
26
+ # name (plain queue — nothing left over) or truncates it at the first
27
+ # pipe and puts the real pid in the nonce slot (pipe-bearing queue).
28
+ # Both misreads are refused: the leftover must be a real queue name and
29
+ # the pseudo-nonce must be nonce-shaped (see wide_tail).
23
30
  def parse_full(key)
24
31
  parts = key.split('|')
25
32
  owner_tails(parts).each do |host, pid, nonce, width|
@@ -57,8 +64,14 @@ module Wurk
57
64
  end
58
65
 
59
66
  # `<host>|<pid>|<nonce>|<idx>` — the shape every current process writes.
67
+ # The nonce slot must carry PROCESS_NONCE's exact shape (NONCE_PATTERN):
68
+ # on a pre-nonce key read wide it holds the real pid, which is never 12
69
+ # hex, so the guard is what falls such a key through to narrow_tail
70
+ # instead of misreading it. Guarded here rather than in parse_full
71
+ # because parse_owner reads through owner_tails too — one guard, and the
72
+ # two parsers agree on every input.
60
73
  def wide_tail(parts)
61
- [parts[-4], parts[-3].to_i, parts[-2], 4] if parts.size >= 4 && integer?(parts[-3])
74
+ [parts[-4], parts[-3].to_i, parts[-2], 4] if parts.size >= 4 && integer?(parts[-3]) && nonce?(parts[-2])
62
75
  end
63
76
 
64
77
  # `<host>|<pid>|<idx>` — written before the nonce existed. Such a list can
@@ -71,6 +84,10 @@ module Wurk
71
84
  def integer?(str)
72
85
  str.is_a?(String) && str.match?(/\A\d+\z/)
73
86
  end
87
+
88
+ def nonce?(str)
89
+ str.is_a?(String) && str.match?(NONCE_PATTERN)
90
+ end
74
91
  end
75
92
  end
76
93
  end
@@ -23,15 +23,15 @@ module Wurk
23
23
  # Liveness is decided per owner:
24
24
  # * our own host and nonce — the pid was minted in our PID namespace, so
25
25
  # the OS is authoritative: `Process.kill(0, pid)`. This is instant and
26
- # ignores a stale `processes` SET entry whose 60s TTL hasn't lapsed
27
- # yet, so a `kill -9`ed sibling is reclaimed the moment the supervisor
28
- # reaps it rather than 60s later. (Pid reuse by an unrelated process in
29
- # the same tree is the one blind spot — the supervisor respawns with a
30
- # fresh pid, so it does not arise in practice.)
26
+ # ignores a stale `processes` SET member — the SET carries no TTL, so
27
+ # the member lingers after its heartbeat hash expires (60s) until
28
+ # `ProcessSet#cleanup` prunes it. A `kill -9`ed sibling is reclaimed
29
+ # the moment the supervisor reaps it, not 60s later. (Pid reuse by an
30
+ # unrelated process in the same tree is the one blind spot — the
31
+ # supervisor respawns with a fresh pid, so it does not arise in practice.)
31
32
  # * any other incarnation — its pid means nothing in our namespace, so we
32
- # trust the heartbeat: the owner is alive iff its identity is a live
33
- # `processes` member (one whose `info` hash still exists). Such reclaim
34
- # therefore waits out the 60s heartbeat TTL, exactly as the spec says.
33
+ # trust the heartbeat: the owner is alive iff its identity's `info`
34
+ # hash still exists. Such reclaim waits out the 60s heartbeat TTL.
35
35
  #
36
36
  # The heartbeat verdict is a snapshot taken before a SCAN that can run for
37
37
  # a long time on a big keyspace, and a booting process can claim jobs a
@@ -128,7 +128,10 @@ module Wurk
128
128
  # this component exists to recover from. A straggler is left running
129
129
  # instead — and still referenced, like every other periodic component, so
130
130
  # a restart after a timed-out stop can't spawn a second sweep loop
131
- # alongside it. Its own tick_once rescues and reports.
131
+ # alongside it. The sweep honours @done at its queue/list/page
132
+ # boundaries, so a straggler runs at most one more Redis round trip past
133
+ # the stop, not the rest of the keyspace. Its own tick_once rescues and
134
+ # reports.
132
135
  def stop
133
136
  @mutex.synchronize do
134
137
  @done = true
@@ -153,9 +156,21 @@ module Wurk
153
156
  # One unguarded sweep over every served queue. Returns the number of
154
157
  # jobs reclaimed (re-queued or killed). Public so boot paths and tests
155
158
  # can drive a deterministic pass without the cluster lock.
159
+ #
160
+ # Honours #stop at each boundary: a stop bounding its join leaves the
161
+ # sweep a straggler by design, and the straggler finishes the list it is
162
+ # already draining (its LMOVEs stay applied) but starts nothing new —
163
+ # otherwise it keeps requeueing orphans and holding the sweep locks
164
+ # past the stop, into whatever runs next.
156
165
  def reclaim!
157
166
  owners = live_owners
158
- served_queues.sum { |public_q| reclaim_queue(public_q, owners) }
167
+ reclaimed = 0
168
+ served_queues.each do |public_q|
169
+ break if done?
170
+
171
+ reclaimed += reclaim_queue(public_q, owners)
172
+ end
173
+ reclaimed
159
174
  end
160
175
 
161
176
  # One unguarded full-keyspace sweep: every `queue:*|*` private list, even
@@ -166,6 +181,7 @@ module Wurk
166
181
  owners = live_owners
167
182
  reclaimed = 0
168
183
  each_full_private_list do |key, public_q, host, pid, nonce|
184
+ break if done?
169
185
  next unless orphaned?(key, host, pid, nonce, owners)
170
186
 
171
187
  reclaimed += drain(key, public_q)
@@ -189,6 +205,7 @@ module Wurk
189
205
  def reclaim_queue(public_q, owners)
190
206
  reclaimed = 0
191
207
  each_private_list(public_q) do |key, host, pid, nonce|
208
+ break if done?
192
209
  next unless orphaned?(key, host, pid, nonce, owners)
193
210
 
194
211
  reclaimed += drain(key, public_q)
@@ -202,6 +219,8 @@ module Wurk
202
219
  def each_private_list(public_q)
203
220
  cursor = '0'
204
221
  loop do
222
+ break if done?
223
+
205
224
  cursor, keys = redis(idempotent: true) do |c|
206
225
  c.call('SCAN', cursor, 'MATCH', "#{public_q}|*", 'COUNT', SCAN_COUNT)
207
226
  end
@@ -220,6 +239,8 @@ module Wurk
220
239
  def each_full_private_list
221
240
  cursor = '0'
222
241
  loop do
242
+ break if done?
243
+
223
244
  cursor, keys = redis(idempotent: true) do |c|
224
245
  c.call('SCAN', cursor, 'MATCH', "#{Keys::QUEUE_PREFIX}*|*", 'COUNT', FULL_SCAN_COUNT)
225
246
  end
@@ -245,8 +266,11 @@ module Wurk
245
266
  # The nonce settles it: minted once per process image and inherited across
246
267
  # fork, so a key carrying ours provably came from this very process tree.
247
268
  # Every other owner goes through the namespace-blind heartbeat — alive iff
248
- # its identity is a live `processes` member. A pre-nonce key can only be
249
- # matched on the `<host>:<pid>` prefix of that identity.
269
+ # its identity's `info` hash still exists. `processes` membership is not
270
+ # liveness: the SET carries no TTL, so a member outlives the hash that
271
+ # proves it alive, and a nonce-bearing identity is alive on its hash
272
+ # alone even once it has been pruned from the SET. A pre-nonce key can
273
+ # only be matched on the `<host>:<pid>` prefix of that identity.
250
274
  def orphaned?(key, host, pid, nonce, owners)
251
275
  return !local_pid_alive?(pid) if nonce == process_nonce && host == hostname
252
276
  return false if owners.include?(owner_key(host, pid, nonce))
@@ -5,10 +5,21 @@ require_relative '../keys'
5
5
  module Wurk
6
6
  class Fetcher
7
7
  # The Reaper's report on a `queue:*|*` key that is not a private list it
8
- # can read the owner of — a Sidekiq Pro super_fetch list in a shape Wurk
9
- # does not write, say, left behind by a live migration. Nothing will ever
10
- # reclaim its jobs, so it is said once per key per process (WARN, with the
11
- # list's length) rather than skipped in silence on every sweep.
8
+ # can read the owner of — a list whose owner tail does not parse, say
9
+ # `queue:<queue>|<host>|not-a-pid|<index>`, left behind by a producer that
10
+ # is not Wurk. Sidekiq Pro's four-part shape
11
+ # (`queue:<queue>|<host>|<pid>|<index>`) parses as the pre-nonce tail and
12
+ # is reclaimed, so Pro's lists are not reported. Nothing will ever reclaim
13
+ # the jobs of an unparseable list, so it is said once per key per process
14
+ # (WARN, with the list's length) rather than skipped in silence on every
15
+ # sweep.
16
+ #
17
+ # The dedup memory is an in-memory ::Set in THIS process only: one
18
+ # UnparseableKeys per Reaper, one Reaper per Launcher, one Launcher per
19
+ # worker process, all of them built after the fork. Nothing about it
20
+ # reaches Redis — no key, no TTL, nothing shared — so there is no
21
+ # fleet-wide suppression: every worker that sweeps the key logs it for
22
+ # itself, and a restart starts with an empty set and reports it again.
12
23
  #
13
24
  # A public queue whose own name contains `|` matches the same SCAN pattern,
14
25
  # so a key whose name is in the `queues` SET is not reported.
@@ -32,9 +43,13 @@ module Wurk
32
43
  @reported.clear if @reported.size >= MEMORY
33
44
  @reported << key
34
45
  @config.logger.warn do
35
- "reaper: #{key.inspect} looks like a reliable-fetch private list but does not match " \
36
- "queue:<queue>|<host>|<pid>|<nonce>|<index>; its #{size} job(s) will not be recovered " \
37
- 'automatically. Inspect it and LMOVE them back onto their queue by hand if they belong to a dead process.'
46
+ "reaper: #{key.inspect} looks like a reliable-fetch private list but matches neither " \
47
+ 'queue:<queue>|<host>|<pid>|<nonce>|<index> nor the pre-nonce ' \
48
+ "queue:<queue>|<host>|<pid>|<index>; its #{size} job(s) will not be recovered " \
49
+ 'automatically. Inspect it and LMOVE them back onto their queue by hand ' \
50
+ 'if they belong to a dead process. ' \
51
+ 'Reported once per process: every worker that sees the key logs it for itself, ' \
52
+ 'and it is reported again after a restart.'
38
53
  end
39
54
  rescue StandardError => e
40
55
  @config.handle_exception(e, context: 'wurk-reaper')
data/lib/wurk/fetcher.rb CHANGED
@@ -5,7 +5,12 @@ module Wurk
5
5
  # we ship and the only one we recommend — BLMOVE-based reliable fetch.
6
6
  # No "basic fetch" mode.
7
7
  class Fetcher
8
- def retrieve_work; end
8
+ # Instant nil spins Processor#run, which immediately fetches again. Concrete
9
+ # implementations must block or back off before returning nil when empty.
10
+ def retrieve_work
11
+ raise NotImplementedError, "#{self.class} must override #retrieve_work and block or back off when empty"
12
+ end
13
+
9
14
  def bulk_requeue(in_progress); end
10
15
 
11
16
  # Quiet hook: Manager#quiet calls this so retrieve_work can short-circuit
data/lib/wurk/health.rb CHANGED
@@ -6,7 +6,11 @@ require_relative 'metrics/prometheus'
6
6
 
7
7
  module Wurk
8
8
  # Thin HTTP listener for k8s liveness/readiness probes. Optional, off by
9
- # default — opt in with `config.health_check(port: 7433)`.
9
+ # default — opt in with `config.health_check(port: 7433)`, then scrape
10
+ # GET /metrics on that configured port (7433 in this example).
11
+ # Mounting Wurk::Engine does not expose Prometheus metrics: this deliberate
12
+ # separation keeps internal metrics off the host application's HTTP surface
13
+ # so access can be restricted independently on the dedicated listener port.
10
14
  #
11
15
  # Endpoints:
12
16
  # * GET /live → 200 while the Launcher is running (not in quiet/stop).
@@ -69,7 +69,7 @@ module Wurk
69
69
  private
70
70
 
71
71
  def elapsed(start)
72
- (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(3)
72
+ ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000).round(3)
73
73
  end
74
74
 
75
75
  # The attribute list is a host setting (defaults to %w[bid tags]), so the
data/lib/wurk/job_util.rb CHANGED
@@ -152,6 +152,9 @@ module Wurk
152
152
  def valid_at?(item) = !item.key?('at') || item['at'].is_a?(Numeric)
153
153
  def valid_tags?(item) = !item.key?('tags') || item['tags'].is_a?(Array)
154
154
 
155
+ # The cap binds the COERCED value, not the raw one: 1_000_000_000.7 is
156
+ # valid and stores as 1_000_000_000 (§2.2 / §9: over 1_000_000_000 after
157
+ # coercion rejected; exactly 1_000_000_000 is valid).
155
158
  def valid_retry_for?(item)
156
159
  return true unless item.key?('retry_for')
157
160
 
@@ -165,7 +168,7 @@ module Wurk
165
168
  def numeric_retry_for(value)
166
169
  case value
167
170
  when Integer then value
168
- when Numeric then value.to_i
171
+ when Numeric then (value.to_i if value.finite? && value.real?)
169
172
  when String then (Integer(value, 10) if value.match?(/\A-?\d+\z/))
170
173
  end
171
174
  end
data/lib/wurk/launcher.rb CHANGED
@@ -118,7 +118,7 @@ module Wurk
118
118
  @done = true
119
119
  @managers.each(&:quiet)
120
120
  @poller&.terminate
121
- # The cron poller is intentionally NOT terminated here: a USR1-quieted
121
+ # The cron poller is intentionally NOT terminated here: a TSTP-quieted
122
122
  # leader still enqueues periodic jobs — it only stops fetching for itself.
123
123
  # Loops stop only on full shutdown (#stop). Spec: sidekiq-ent.md §2.6.
124
124
  fire_event(:quiet, reverse: true)
@@ -379,8 +379,9 @@ module Wurk
379
379
  # a *quieted* process keeps beating so it publishes `quiet=true` instead of
380
380
  # vanishing (#236). Each tick is rescued on its own — a raise escaping one
381
381
  # beat (a signal re-delivery the kernel refuses, a hook) would otherwise end
382
- # the thread, and a process that stops beating expires out of `processes`
383
- # while still running jobs, which the reaper then reclaims.
382
+ # the thread, and a process that stops beating is still running jobs but
383
+ # stops being LIVE: its heartbeat hash lapses (60s TTL), so the reaper
384
+ # reclaims its jobs, and cleanup prunes its stale `processes` entry.
384
385
  def start_heartbeat
385
386
  @beat_timer.run do
386
387
  heartbeat
data/lib/wurk/lua.rb CHANGED
@@ -4,7 +4,7 @@ require 'digest'
4
4
 
5
5
  module Wurk
6
6
  # EVALSHA-cached Lua scripts. Loaded once per pool, never re-uploaded.
7
- # Bulk enqueue, multi-pop, atomic schedule promotion, batch ops.
7
+ # Multi-pop, atomic schedule promotion, batch ops.
8
8
  #
9
9
  # Source strings are intentionally bare — the SHA1 of each is computed
10
10
  # at load time and is the same value Redis reports from `SCRIPT LOAD`.
@@ -24,18 +24,6 @@ module Wurk
24
24
  end
25
25
  LUA
26
26
 
27
- # Bulk enqueue to a single queue.
28
- # KEYS = [queue_list, queues_set]
29
- # ARGV = [queue_name, job_json, ...]
30
- # Returns the number of jobs pushed.
31
- BULK_PUSH = <<~LUA
32
- redis.call("sadd", KEYS[2], ARGV[1])
33
- for i = 2, #ARGV do
34
- redis.call("lpush", KEYS[1], ARGV[i])
35
- end
36
- return #ARGV - 1
37
- LUA
38
-
39
27
  # Pro reliable scheduler: atomically promote all due jobs in a sorted
40
28
  # set to their target queues. Pure-Ruby promotion does ZRANGE → ZREM →
41
29
  # LPUSH non-atomically and can lose jobs on a mid-step crash.
@@ -224,7 +212,6 @@ module Wurk
224
212
 
225
213
  SCRIPTS = {
226
214
  zpopbyscore: ZPOPBYSCORE,
227
- bulk_push: BULK_PUSH,
228
215
  reliable_schedule_promote: RELIABLE_SCHEDULE_PROMOTE,
229
216
  reliable_requeue: RELIABLE_REQUEUE,
230
217
  fast_delete_job: FAST_DELETE_JOB,
@@ -64,12 +64,31 @@ module Wurk
64
64
  # Bucket start times oldest→newest and their keys: one per minute for
65
65
  # `minutes`, one per 10 minutes for `hours` (which wins when both given).
66
66
  def window_buckets(minutes, hours, now)
67
- stride, keyproc = Query::ROLLUPS.fetch(hours ? :hourly : :minutely)
68
- count = hours ? cap_hours!(hours) * 6 : cap_minutes!(minutes)
67
+ minutes, buckets = promote_minute_window(minutes, hours)
68
+ stride, keyproc = Query::ROLLUPS.fetch(buckets ? :hourly : :minutely)
69
+ count = buckets || cap_minutes!(minutes)
69
70
  stamps = bucket_starts(now, stride, count * stride).map { |s| ::Time.at(s).utc }
70
71
  [stamps, stamps.map(&keyproc)]
71
72
  end
72
73
 
74
+ # 10-minute bucket counts for the requested window — nil stays on the
75
+ # per-minute buckets. An explicit `hours:` is six buckets per hour
76
+ # (cap_hours! still bounds it). A minute window past MAX_MINUTES
77
+ # promotes onto the 10-minute buckets (whose 30-day retention covers
78
+ # it) instead of raising; it still WindowTooWides past MAX_HOURS * 60
79
+ # minutes — checked in minutes first so the error names the unit the
80
+ # caller passed. The promoted window is counted in 10-minute buckets,
81
+ # ceil(minutes / 10), not rounded up to whole hours (#726): 490 minutes
82
+ # is 49 buckets, not 9 hours' 54, so the totals never reach past the
83
+ # window the caller asked for by more than the trailing partial bucket.
84
+ def promote_minute_window(minutes, hours)
85
+ return [minutes, cap_hours!(hours) * 6] unless hours.nil?
86
+ return [minutes, nil] unless minutes && minutes > Query::MAX_MINUTES
87
+
88
+ checked = check_window!(Integer(minutes), Query::MAX_HOURS * 60, 'minutes')
89
+ [nil, (checked / 10.0).ceil]
90
+ end
91
+
73
92
  def sum_totals(hashes)
74
93
  totals = ::Hash.new { |h, k| h[k] = { p: 0, f: 0, ms: 0 } }
75
94
  hashes.each do |raw|
@@ -14,7 +14,7 @@ module Wurk
14
14
  # reads what Wurk wrote (spec: docs/target/sidekiq-free.md §1.6, §20).
15
15
  #
16
16
  # j|YYMMDD|H:MM HASH per-minute bucket, TTL = SHORT_TERM (8h)
17
- # j|YYMMDD|H:M HASH 10-minute bucket (minute's tens digit), TTL = MID_TERM (3d)
17
+ # j|YYMMDD|H:M HASH 10-minute bucket (minute's tens digit), TTL = MID_TERM (30d)
18
18
  # <klass>|p INT executions, failures included
19
19
  # <klass>|f INT failures
20
20
  # <klass>|ms INT total ms of the executions that did not fail
@@ -32,8 +32,11 @@ module Wurk
32
32
  class History
33
33
  include Wurk::Middleware::ServerMiddleware
34
34
 
35
- # Upstream's ExecutionTracker constants, same names.
36
- MID_TERM = 3 * 24 * 60 * 60
35
+ # Upstream's ExecutionTracker constants, same names. MID_TERM diverges:
36
+ # upstream TTLs the 10-minute buckets at 3 days, but Wurk's dashboard
37
+ # serves 7d/30d tabs off them (Query::MAX_HOURS = 720), so they must
38
+ # live 30 days. Longer retention changes no key, field, or score.
39
+ MID_TERM = 30 * 24 * 60 * 60
37
40
  SHORT_TERM = 8 * 60 * 60
38
41
 
39
42
  MINUTE_KEY_FORMAT = 'j|%y%m%d|%-H:%M'
@@ -62,7 +65,10 @@ module Wurk
62
65
  rescue Wurk::Job::Interrupted, Wurk::Job::DeadlineExceeded
63
66
  # Neither is the job failing. A cooperative interruption is not a
64
67
  # failure: InterruptHandler self-prepends, so it sits *outside* this
65
- # middleware, Interrupted passes through here before it becomes a
68
+ # middleware (registered by
69
+ # `Wurk.configuration.server_middleware.prepend(Wurk::Middleware::InterruptHandler)`
70
+ # in lib/wurk/middleware/interrupt_handler.rb:50); Interrupted passes
71
+ # through here before it becomes a
66
72
  # JobRetry::Skip, and without this arm one interrupted IterableJob
67
73
  # books a spurious `<klass>|f` (#394). Upstream's
68
74
  # ExecutionTracker#track books `p` + `ms` for that Skip and reserves
@@ -72,7 +78,9 @@ module Wurk
72
78
  # docs/plans/2026/08/07/101-beyond-sidekiq/00-semantics-signoff.md §1.
73
79
  #
74
80
  # A job cut by its absolute deadline lands in the same bucket for a
75
- # plainer reason: Middleware::Expiry, also outside this one, catches
81
+ # plainer reason: Middleware::Expiry, also outside this one (registered
82
+ # by `Wurk.configuration.server_middleware.add(Wurk::Middleware::Expiry)`
83
+ # in lib/wurk/middleware/expiry.rb:111), catches
76
84
  # that raise and books the job `expired`. Booking `f` here too would
77
85
  # put one abandoned job in two of the three counts an operator
78
86
  # subtracts (executed = processed - failed - expired), and would make
@@ -20,7 +20,11 @@ module Wurk
20
20
  # dashboard JSON API.
21
21
  class Query
22
22
  MAX_MINUTES = 480
23
- MAX_HOURS = 72
23
+ # Class-level dashboard cap: the 10-minute buckets live MID_TERM
24
+ # (30 days), so a 720-hour window is fully backed by data. The
25
+ # instance-side clamp below keeps upstream's 72h cap — Sidekiq parity
26
+ # is pinned by test/parity/metrics_query_test.rb.
27
+ MAX_HOURS = 720
24
28
  MAX_QUEUE_SERIES = 25
25
29
 
26
30
  class WindowTooWide < ::ArgumentError; end
@@ -129,10 +133,13 @@ module Wurk
129
133
 
130
134
  # Upstream's DoS caps: an oversized minute window falls back to the
131
135
  # 60-minute default, an oversized hour window to the 72h ceiling.
136
+ # The 72 is upstream's literal, not MAX_HOURS: the class-level cap
137
+ # moved to 720 with the 30-day bucket retention, but this Sidekiq-
138
+ # compatible instance API must keep clamping at 72h.
132
139
  def clamp(minutes, hours)
133
140
  minutes = 60 unless minutes || hours
134
141
  minutes = 60 if minutes && minutes > MAX_MINUTES
135
- [minutes, hours && [hours, MAX_HOURS].min]
142
+ [minutes, hours && [hours, 72].min]
136
143
  end
137
144
 
138
145
  def add_bucket(result, time, hash, class_filter)