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.
- checksums.yaml +4 -4
- data/README.md +29 -9
- data/app/controllers/wurk/api_controller.rb +49 -10
- data/config/routes.rb +20 -12
- data/lib/active_job/queue_adapters/wurk_adapter.rb +8 -2
- data/lib/sidekiq/rails.rb +5 -0
- data/lib/wurk/api/read_only.rb +1 -1
- data/lib/wurk/batch/server_middleware.rb +27 -0
- data/lib/wurk/capsule.rb +57 -2
- data/lib/wurk/cli.rb +4 -2
- data/lib/wurk/client.rb +16 -11
- data/lib/wurk/compat.rb +12 -2
- data/lib/wurk/configuration.rb +86 -7
- data/lib/wurk/encryption.rb +47 -8
- data/lib/wurk/fetcher/private_list_key.rb +19 -2
- data/lib/wurk/fetcher/reaper.rb +36 -12
- data/lib/wurk/fetcher/unparseable_keys.rb +22 -7
- data/lib/wurk/fetcher.rb +6 -1
- data/lib/wurk/health.rb +5 -1
- data/lib/wurk/job_logger.rb +1 -1
- data/lib/wurk/job_util.rb +4 -1
- data/lib/wurk/launcher.rb +4 -3
- data/lib/wurk/lua.rb +1 -14
- data/lib/wurk/metrics/dashboard_series.rb +21 -2
- data/lib/wurk/metrics/history.rb +13 -5
- data/lib/wurk/metrics/query.rb +9 -2
- data/lib/wurk/metrics/statsd.rb +8 -0
- data/lib/wurk/middleware/chain.rb +85 -13
- data/lib/wurk/pool_checkout.rb +1 -1
- data/lib/wurk/queue_slot.rb +19 -9
- data/lib/wurk/rails_boot.rb +17 -6
- data/lib/wurk/railtie.rb +7 -3
- data/lib/wurk/redis_connection.rb +4 -2
- data/lib/wurk/unique.rb +11 -8
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +42 -9
- data/lib/wurk/web/enterprise.rb +4 -3
- data/lib/wurk/web/extension.rb +74 -39
- data/lib/wurk.rb +5 -2
- data/vendor/assets/dashboard/assets/{ArgsValue-DsPb_KZ-.js → ArgsValue-DDqX6XWH.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-BCIXJOpZ.js → BatchDetail-lIytcn2C.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-IjaQfXW6.js → Batches-BEumt-1O.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-CaQrf1Ve.js → Busy-Vk9HmZcN.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-B18EHn_Y.js → Cron-CWcM-Cfr.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dashboard-DdT5ZaUg.js → Dashboard-Bgv1kusv.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dead-DWIy-Mu8.js → Dead-Di5Rct2H.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-LZY5-imc.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-BDo-7IAy.js +2 -0
- data/vendor/assets/dashboard/assets/{FlowDetail-DnexL72n.js → FlowDetail-CehwRLTt.js} +1 -1
- data/vendor/assets/dashboard/assets/{FlowState-Cr4aX6-d.js → FlowState-glK2Tnf8.js} +1 -1
- data/vendor/assets/dashboard/assets/{Flows-DO6EimbZ.js → Flows-cgy-Fsbp.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-aPjgq-U9.js → JobDetailModal-Cuk3jruU.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-DxpwWCNe.js → Limiters-Ci8G2yZG.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-CQFUeZTR.js +1 -0
- data/vendor/assets/dashboard/assets/{NotFound-BfP9uAV9.js → NotFound-CYjhuDsI.js} +1 -1
- data/vendor/assets/dashboard/assets/{PageHeader-DbC97afG.js → PageHeader-DjIAFTyX.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-BcaHreMe.js → Profiles-DTowqIRg.js} +1 -1
- data/vendor/assets/dashboard/assets/{Queues-ZlBc5tyD.js → Queues-DUGRPjME.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-Bg3nkeiL.js → Retries-SJ3BaKC0.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-BrMZPZGl.js → Scheduled-DxSRpmFZ.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-B5X5pGht.js → Search-bBbi5JNK.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-uDyf4PN0.js → charts-Bg2YBcaD.js} +1 -1
- data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +1 -0
- data/vendor/assets/dashboard/assets/index-BOTVSP9M.js +141 -0
- data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +1 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-O3-5rMk3.js → useResetPageOnEmpty-BRPNyV2M.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-xfnwjQAu.js → useSort-B-BLJik7.js} +1 -1
- data/vendor/assets/dashboard/assets/{utils-BIKHwNKB.js → utils-CGyrKpTh.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +30 -44
- data/vendor/assets/dashboard/assets/Extension-BjBUWhHf.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-DwH3PXXt.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-C3JL1GIH.js +0 -1
- data/vendor/assets/dashboard/assets/i18n-CJeYfeVt.js +0 -1
- data/vendor/assets/dashboard/assets/index-B36QCAf-.css +0 -1
- data/vendor/assets/dashboard/assets/index-DQujgZgH.js +0 -141
data/lib/wurk/configuration.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
777
|
-
|
|
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
|
-
|
|
784
|
-
|
|
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])
|
data/lib/wurk/encryption.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
data/lib/wurk/fetcher/reaper.rb
CHANGED
|
@@ -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
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# the
|
|
30
|
-
#
|
|
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
|
|
33
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
|
249
|
-
#
|
|
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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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).
|
data/lib/wurk/job_logger.rb
CHANGED
|
@@ -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
|
|
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
|
|
383
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
68
|
-
|
|
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|
|
data/lib/wurk/metrics/history.rb
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/wurk/metrics/query.rb
CHANGED
|
@@ -20,7 +20,11 @@ module Wurk
|
|
|
20
20
|
# dashboard JSON API.
|
|
21
21
|
class Query
|
|
22
22
|
MAX_MINUTES = 480
|
|
23
|
-
|
|
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,
|
|
142
|
+
[minutes, hours && [hours, 72].min]
|
|
136
143
|
end
|
|
137
144
|
|
|
138
145
|
def add_bucket(result, time, hash, class_filter)
|