wurk 1.3.0 → 1.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/lib/wurk/batch/callbacks.rb +82 -12
  4. data/lib/wurk/batch/death_handler.rb +7 -4
  5. data/lib/wurk/batch/server_middleware.rb +1 -1
  6. data/lib/wurk/batch.rb +121 -15
  7. data/lib/wurk/capsule.rb +5 -4
  8. data/lib/wurk/cli.rb +48 -14
  9. data/lib/wurk/client/buffered.rb +193 -43
  10. data/lib/wurk/client.rb +87 -14
  11. data/lib/wurk/compat.rb +1 -1
  12. data/lib/wurk/component.rb +2 -2
  13. data/lib/wurk/configuration.rb +10 -2
  14. data/lib/wurk/cron.rb +94 -37
  15. data/lib/wurk/deploy.rb +5 -3
  16. data/lib/wurk/embedded.rb +13 -0
  17. data/lib/wurk/fetcher/reaper.rb +113 -56
  18. data/lib/wurk/fetcher/reliable.rb +62 -9
  19. data/lib/wurk/heartbeat.rb +22 -10
  20. data/lib/wurk/history.rb +13 -1
  21. data/lib/wurk/launcher.rb +133 -66
  22. data/lib/wurk/leader.rb +29 -10
  23. data/lib/wurk/limiter/base.rb +8 -10
  24. data/lib/wurk/limiter/bucket.rb +1 -1
  25. data/lib/wurk/limiter/concurrent.rb +27 -22
  26. data/lib/wurk/limiter/window.rb +13 -11
  27. data/lib/wurk/limiter.rb +7 -4
  28. data/lib/wurk/lua.rb +97 -14
  29. data/lib/wurk/manager.rb +29 -13
  30. data/lib/wurk/metrics/history.rb +4 -3
  31. data/lib/wurk/metrics/queue_rollup.rb +13 -1
  32. data/lib/wurk/metrics/rollup.rb +13 -1
  33. data/lib/wurk/middleware/interrupt_handler.rb +7 -6
  34. data/lib/wurk/middleware/poison_pill.rb +70 -29
  35. data/lib/wurk/middleware.rb +2 -2
  36. data/lib/wurk/pool_checkout.rb +29 -0
  37. data/lib/wurk/process_set.rb +10 -5
  38. data/lib/wurk/processor.rb +6 -0
  39. data/lib/wurk/profiler.rb +3 -2
  40. data/lib/wurk/queue.rb +10 -7
  41. data/lib/wurk/rails_boot.rb +38 -7
  42. data/lib/wurk/redis_client_adapter.rb +48 -4
  43. data/lib/wurk/redis_options.rb +142 -0
  44. data/lib/wurk/redis_pool.rb +102 -39
  45. data/lib/wurk/scheduled.rb +30 -2
  46. data/lib/wurk/stats.rb +14 -9
  47. data/lib/wurk/swarm/child_boot.rb +12 -0
  48. data/lib/wurk/swarm.rb +174 -33
  49. data/lib/wurk/timer_loop.rb +14 -0
  50. data/lib/wurk/version.rb +1 -1
  51. data/lib/wurk/web/enterprise.rb +58 -6
  52. data/lib/wurk/web/extension.rb +1 -1
  53. data/lib/wurk/web/search.rb +5 -3
  54. data/lib/wurk.rb +53 -2
  55. data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-OmC5NPgw.js} +1 -1
  56. data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-CIpai7St.js} +1 -1
  57. data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-A_kwSR6Q.js} +1 -1
  58. data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-BG7HTqlp.js} +1 -1
  59. data/vendor/assets/dashboard/assets/{Dashboard-B9rOrkzk.js → Dashboard-A_ToqHoo.js} +1 -1
  60. data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-8J21jMyK.js} +1 -1
  61. data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +1 -0
  62. data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-Fh_Ae7UW.js} +1 -1
  63. data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-Ceng0PMB.js} +1 -1
  64. data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-CruDWvNZ.js} +1 -1
  65. data/vendor/assets/dashboard/assets/{Metrics-BBTDxcaE.js → Metrics-CIT7VCoN.js} +1 -1
  66. data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +1 -0
  67. data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-D86FYohJ.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-Bz1O1D-i.js} +1 -1
  69. data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-B6h2akTu.js} +1 -1
  70. data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-OOu22e5s.js} +1 -1
  71. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
  72. data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +141 -0
  73. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-dVPGEWzn.js} +1 -1
  74. data/vendor/assets/dashboard/index.html +2 -2
  75. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  76. metadata +22 -20
  77. data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
  78. data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
  79. data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
  80. data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
@@ -32,7 +32,7 @@ module Wurk
32
32
  # absence (process never registered) and expiry (heartbeat lapsed,
33
33
  # info field gone) both return nil.
34
34
  def self.[](identity)
35
- exists, fields = Wurk.redis do |conn|
35
+ exists, fields = Wurk.redis(idempotent: true) do |conn|
36
36
  conn.pipelined do |pipe|
37
37
  pipe.call('SISMEMBER', Keys::PROCESSES, identity)
38
38
  pipe.call('HMGET', identity, *LOOKUP_FIELDS)
@@ -48,6 +48,11 @@ module Wurk
48
48
  # don't dogpile the prune. Returns the number of identities removed
49
49
  # (or 0 when the lock was held by someone else).
50
50
  #
51
+ # No apply-safety claim, unlike the reads around it: a replay re-decides
52
+ # which identities are dead, so it could prune one that registered in
53
+ # between and hasn't written `info` yet. A blip here just skips one prune —
54
+ # the next caller a minute later does it.
55
+ #
51
56
  # Spec: docs/target/sidekiq-free.md §31.17.
52
57
  def cleanup
53
58
  return 0 unless acquired_cleanup_lock?
@@ -84,7 +89,7 @@ module Wurk
84
89
  # SCARD over `processes`. Not pruned — may include identities whose
85
90
  # heartbeat has lapsed. Use `each` for the accurate count.
86
91
  def size
87
- Wurk.redis { |conn| conn.call('SCARD', Keys::PROCESSES) }
92
+ Wurk.redis(idempotent: true) { |conn| conn.call('SCARD', Keys::PROCESSES) }
88
93
  end
89
94
 
90
95
  # Sum of `concurrency` across live processes. Iterates `each` so dead
@@ -104,7 +109,7 @@ module Wurk
104
109
  # `||=` with empty-string fallback distinguishes "leader is unset" from
105
110
  # "memoization not yet computed".
106
111
  def leader
107
- @leader ||= Wurk.redis { |c| c.call('GET', 'dear-leader') } || ''
112
+ @leader ||= Wurk.redis(idempotent: true) { |c| c.call('GET', 'dear-leader') } || ''
108
113
  end
109
114
 
110
115
  class << self
@@ -134,7 +139,7 @@ module Wurk
134
139
  private
135
140
 
136
141
  def fetch_each_rows
137
- Wurk.redis do |conn|
142
+ Wurk.redis(idempotent: true) do |conn|
138
143
  procs = conn.call('SMEMBERS', Keys::PROCESSES).sort
139
144
  next [] if procs.empty?
140
145
 
@@ -223,7 +228,7 @@ module Wurk
223
228
  # Compares identity against the `dear-leader` STRING. Ent-only;
224
229
  # always false in OSS/free.
225
230
  def leader?
226
- Wurk.redis { |c| c.call('GET', 'dear-leader') == identity }
231
+ Wurk.redis(idempotent: true) { |c| c.call('GET', 'dear-leader') == identity }
227
232
  end
228
233
 
229
234
  private
@@ -164,6 +164,12 @@ module Wurk
164
164
  job_hash = parse_or_kill(jobstr, uow)
165
165
  return if job_hash.nil?
166
166
 
167
+ # The fetcher never parses, so hand it the jid we just read: the ACK
168
+ # retires this job's poison-pill recovery counter inside the round trip
169
+ # it already makes. A fetcher plugged in via `config[:fetch_class]` has
170
+ # no jid slot and simply ACKs — the counter then ages out on its 72h TTL.
171
+ uow.jid = job_hash['jid'] if uow.respond_to?(:jid=)
172
+
167
173
  ack = false
168
174
  begin
169
175
  Thread.handle_interrupt(Wurk::Shutdown => :never) do
data/lib/wurk/profiler.rb CHANGED
@@ -5,6 +5,7 @@ require 'zlib'
5
5
  require 'stringio'
6
6
  require 'tempfile'
7
7
  require_relative 'keys'
8
+ require_relative 'pool_checkout'
8
9
 
9
10
  module Wurk
10
11
  # Job profiling (Sidekiq 8.0+, OSS). When a job is pushed with a `profile`
@@ -113,8 +114,8 @@ module Wurk
113
114
  end
114
115
  end
115
116
 
116
- def with_pool(pool, &)
117
- pool ? pool.with(&) : Wurk.redis(&)
117
+ def with_pool(pool, idempotent: false, &)
118
+ pool ? PoolCheckout.with(pool, idempotent, &) : Wurk.redis(idempotent:, &)
118
119
  end
119
120
 
120
121
  def now
data/lib/wurk/queue.rb CHANGED
@@ -25,7 +25,7 @@ module Wurk
25
25
 
26
26
  # @return [Array<Queue>] one per known queue, sorted by name.
27
27
  def self.all
28
- names = Wurk.redis { |conn| conn.call('SMEMBERS', Keys::QUEUES_SET) }
28
+ names = Wurk.redis(idempotent: true) { |conn| conn.call('SMEMBERS', Keys::QUEUES_SET) }
29
29
  names.sort.map { |n| new(n) }
30
30
  end
31
31
 
@@ -35,12 +35,12 @@ module Wurk
35
35
  end
36
36
 
37
37
  def size
38
- Wurk.redis { |conn| conn.call('LLEN', @rname) }
38
+ Wurk.redis(idempotent: true) { |conn| conn.call('LLEN', @rname) }
39
39
  end
40
40
 
41
41
  # Seconds since the oldest job (tail of LIST) was enqueued. 0.0 when empty.
42
42
  def latency
43
- payload = Wurk.redis { |conn| conn.call('LRANGE', @rname, -1, -1).first }
43
+ payload = Wurk.redis(idempotent: true) { |conn| conn.call('LRANGE', @rname, -1, -1).first }
44
44
  return 0.0 if payload.nil?
45
45
 
46
46
  JobRecord.latency_from(Wurk.load_json(payload)['enqueued_at'])
@@ -51,19 +51,19 @@ module Wurk
51
51
  # True iff this queue's name is a member of the `paused` SET. Wurk
52
52
  # implements the Pro contract for free; fetchers consult the same set.
53
53
  def paused?
54
- Wurk.redis { |conn| conn.call('SISMEMBER', Keys::PAUSED_SET, @name) } == 1
54
+ Wurk.redis(idempotent: true) { |conn| conn.call('SISMEMBER', Keys::PAUSED_SET, @name) } == 1
55
55
  end
56
56
 
57
57
  # Pause new fetches against this queue. Idempotent — `SADD` returns
58
58
  # 0 when the name was already present. In-flight jobs are untouched.
59
59
  def pause! # rubocop:disable Naming/PredicateMethod
60
- Wurk.redis { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
60
+ Wurk.redis(idempotent: true) { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
61
61
  true
62
62
  end
63
63
 
64
64
  # Resume fetches. Idempotent.
65
65
  def unpause! # rubocop:disable Naming/PredicateMethod
66
- Wurk.redis { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
66
+ Wurk.redis(idempotent: true) { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
67
67
  true
68
68
  end
69
69
 
@@ -74,7 +74,7 @@ module Wurk
74
74
  loop do
75
75
  start = page * PAGE_SIZE
76
76
  stop = start + PAGE_SIZE - 1
77
- slice = Wurk.redis { |conn| conn.call('LRANGE', @rname, start, stop) }
77
+ slice = Wurk.redis(idempotent: true) { |conn| conn.call('LRANGE', @rname, start, stop) }
78
78
  slice.each { |value| yield JobRecord.new(value, @name) }
79
79
  break if slice.size < PAGE_SIZE
80
80
 
@@ -91,6 +91,9 @@ module Wurk
91
91
  # UNLINK the list + drop the queue from the `queues` set. Pipelined
92
92
  # so a partial failure leaves at most one of the two ops applied.
93
93
  # Method name is Sidekiq wire-compat — `clear?` would break the alias.
94
+ #
95
+ # Unlike pause!/unpause!, this one can't claim apply-safety: a replay after
96
+ # a lost reply would UNLINK whatever a producer enqueued in between.
94
97
  def clear # rubocop:disable Naming/PredicateMethod
95
98
  Wurk.redis do |conn|
96
99
  conn.pipelined do |pipe|
@@ -8,6 +8,11 @@ module Wurk
8
8
  # boot policy stays pure and unit-testable without the Railtie DSL.
9
9
  # See docs/idea/03-process-model.md for the exact ordering.
10
10
  module RailsBoot
11
+ # What `at_exit` waits for the supervise thread on top of the swarm's own
12
+ # drain budget: one supervise tick to notice the request, plus slack for
13
+ # the final reap.
14
+ DRAIN_JOIN_SLACK = 1
15
+
11
16
  module_function
12
17
 
13
18
  # Invoked from the `wurk.server_mode` initializer, before config/initializers
@@ -103,36 +108,62 @@ module Wurk
103
108
  end
104
109
 
105
110
  def boot_swarm
106
- swarm = Wurk::Swarm.new(topology: Wurk.configuration.topology,
107
- shutdown_timeout: Wurk.configuration[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
111
+ timeout = Wurk.configuration[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT
112
+ swarm = Wurk::Swarm.new(topology: Wurk.configuration.topology, shutdown_timeout: timeout)
113
+ supervisor = nil
114
+ # Registered BEFORE boot, which forks the children one slot at a time: a
115
+ # fork that raises partway through would otherwise leave the children it
116
+ # already spawned with nothing to drain them on host exit. Children
117
+ # inherit the hook — stop_swarm no-ops off the process that forked them.
118
+ at_exit { stop_swarm(swarm, supervisor, timeout + Swarm::SHUTDOWN_GRACE + DRAIN_JOIN_SLACK) }
108
119
  # Co-hosted in the web process (e.g. Puma single mode): the host owns the
109
120
  # process-wide TERM/INT traps. Installing the swarm's own would hijack
110
121
  # them — a deploy TERM would drain the swarm but never stop the HTTP
111
122
  # server. Let the host keep signal ownership and drain the swarm on its
112
123
  # graceful exit (same contract as boot_embedded).
113
124
  swarm.boot(install_signals: false)
114
- at_exit { swarm.shutdown }
115
125
  # supervise must still run somewhere or crashed children never respawn and
116
126
  # memory checks never fire. A background thread keeps the host's main
117
127
  # thread free to serve HTTP.
118
- Thread.new do
128
+ supervisor = Thread.new do
119
129
  swarm.supervise
120
130
  rescue StandardError => e
121
131
  logger.error { "wurk supervisor thread died: #{e.class}: #{e.message}" }
122
132
  end
123
133
  end
124
134
 
135
+ # at_exit fires on the host's main thread, but the supervise thread owns the
136
+ # child table and two threads inside `shutdown` race on it — so request the
137
+ # drain and wait for the supervisor to run it. Draining here is the fallback
138
+ # for when no live supervisor will: it never started (boot raised) or it
139
+ # died early; after one that drained it finds no children and no-ops. A
140
+ # supervisor still alive past the join is wedged mid-drain — leave it be
141
+ # rather than race it; its children self-terminate (OrphanGuard) once this
142
+ # process is gone.
143
+ def stop_swarm(swarm, supervisor, drain_wait)
144
+ return unless swarm.owner?
145
+
146
+ swarm.request_shutdown
147
+ supervisor&.join(drain_wait)
148
+ swarm.shutdown unless supervisor&.alive?
149
+ end
150
+
125
151
  # Sidekiq-embedded parity: a threads-only worker inside the web process, no
126
152
  # fork. Redis validation failure keeps the host serving HTTP (log + carry
127
153
  # on). at_exit drains on the graceful shutdown the web server runs on TERM.
128
- def boot_embedded
129
- instance = Wurk::Embedded.new(Wurk.configuration)
130
- instance.run
154
+ def boot_embedded(embedded = Wurk::Embedded)
155
+ instance = embedded.new(Wurk.configuration)
156
+ # Registered BEFORE run, for the reason boot_swarm registers before the
157
+ # fork: run brings the heartbeat, pollers, managers and health listener up
158
+ # one at a time, and a raise partway through would otherwise leave the ones
159
+ # already running with nothing to drain them on host exit.
131
160
  at_exit { instance.stop }
161
+ instance.run
132
162
  logger.info { 'wurk: running embedded in the web process (config.wurk.embed_in_web) — threads only, no fork' }
133
163
  instance
134
164
  rescue StandardError => e
135
165
  logger.error { "wurk: embedded boot failed: #{e.class}: #{e.message}" }
166
+ instance&.stop
136
167
  nil
137
168
  end
138
169
 
@@ -21,13 +21,18 @@ module Wurk
21
21
 
22
22
  DEPRECATED_COMMANDS = %i[rpoplpush zrangebyscore zrevrange zrevrangebyscore getset hmset setex setnx].to_set
23
23
 
24
+ # Every method here dispatches through `call` rather than `@client.call` so
25
+ # the round-trip odometer below sees method-style commands too — a host
26
+ # block doing `conn.sadd(...)` then `conn.lpush(...)` has to be as
27
+ # replay-protected as one written with `conn.call`. On the pipeline
28
+ # decorator `call` is the same buffered forward it always was.
24
29
  module CompatMethods
25
30
  def info
26
- @client.call('INFO') { |i| i.lines(chomp: true).map { |l| l.split(':', 2) }.select { |l| l.size == 2 }.to_h }
31
+ call('INFO') { |i| i.lines(chomp: true).map { |l| l.split(':', 2) }.select { |l| l.size == 2 }.to_h }
27
32
  end
28
33
 
29
34
  def evalsha(sha, keys, argv)
30
- @client.call('EVALSHA', sha, keys.size, *keys, *argv)
35
+ call('EVALSHA', sha, keys.size, *keys, *argv)
31
36
  end
32
37
 
33
38
  # The Redis commands Sidekiq itself uses — defined eagerly so the
@@ -41,7 +46,7 @@ module Wurk
41
46
 
42
47
  USED_COMMANDS.each do |name|
43
48
  define_method(name) do |*args, **kwargs|
44
- @client.call(name, *args, **kwargs)
49
+ call(name, *args, **kwargs)
45
50
  end
46
51
  end
47
52
 
@@ -52,7 +57,7 @@ module Wurk
52
57
  if DEPRECATED_COMMANDS.include?(args.first)
53
58
  warn("[sidekiq#5788] Redis has deprecated the `#{args.first}` command, called at #{caller(1..1)}")
54
59
  end
55
- @client.call(*args, &)
60
+ call(*args, &)
56
61
  end
57
62
  ruby2_keywords :method_missing if respond_to?(:ruby2_keywords, true)
58
63
 
@@ -64,9 +69,48 @@ module Wurk
64
69
  CompatClient = RedisClient::Decorator.create(CompatMethods)
65
70
 
66
71
  class CompatClient
72
+ # Dispatch methods that put commands on the wire and wait for the reply.
73
+ # SCAN and friends are left out on purpose: they are pure reads, so
74
+ # re-running one applies nothing and cannot make a replay unsafe.
75
+ DISPATCH_METHODS = %i[call call_v call_once call_once_v
76
+ blocking_call blocking_call_v pipelined multi].freeze
77
+
78
+ # Round trips this connection has completed, monotonic for its whole life.
79
+ #
80
+ # {Wurk::RedisPool} replays a failed block only while it can prove nothing
81
+ # in it applied, and a connect-phase error proves that for the command
82
+ # that raised — never for the ones before it. redis-client re-dials a
83
+ # dropped socket mid-block, so `CannotConnectError` surfaces on the second
84
+ # pipeline of a block whose first one already landed. The pool snapshots
85
+ # this counter around the block and refuses the replay once it has moved.
86
+ attr_reader :round_trips
87
+
88
+ def initialize(client)
89
+ super
90
+ @round_trips = 0
91
+ end
92
+
67
93
  def config
68
94
  @client.config
69
95
  end
96
+
97
+ # Counted *after* the call returns: a command that never reached a server
98
+ # leaves the odometer where it was, which is what keeps the pre-apply
99
+ # backoff alive for blocks that failed on their very first round trip.
100
+ DISPATCH_METHODS.each do |name|
101
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
102
+ # def call(...)
103
+ # result = super
104
+ # @round_trips += 1
105
+ # result
106
+ # end
107
+ def #{name}(...)
108
+ result = super
109
+ @round_trips += 1
110
+ result
111
+ end
112
+ RUBY
113
+ end
70
114
  end
71
115
  end
72
116
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis-client'
4
+
5
+ module Wurk
6
+ # Translates a Sidekiq-shaped `config.redis` hash into the exact keyword set
7
+ # redis-client accepts.
8
+ #
9
+ # Sidekiq normalizes the hash itself before handing it over
10
+ # (`sidekiq/redis_client_adapter.rb#client_opts`), so initializers in the wild
11
+ # carry keys redis-client has never known. Wurk used to splat the hash straight
12
+ # into `RedisClient.config`, which surfaced as
13
+ # `ArgumentError: unknown keyword: :network_timeout` — and only inside the
14
+ # forked children, which build their own pools (#283). The parent booted fine,
15
+ # the liveness probe passed, and the swarm respawn loop churned forever
16
+ # processing zero jobs. Hence `validate!`, called from Configuration#redis= so
17
+ # a bad hash raises in the parent where someone can actually see it.
18
+ #
19
+ # Reference: sidekiq 7.3 / 8.1 `client_opts` — namespace rejected,
20
+ # size/pool_timeout dropped, `network_timeout` → `timeout`, `master_name` →
21
+ # `name`, role/driver symbolized, `reconnect_attempts ||= 1`.
22
+ module RedisOptions
23
+ # Consumed by the pool layer (RedisPool / Capsule); never a socket concern.
24
+ POOL_KEYS = %i[size name pool_name pool_timeout on_error].freeze
25
+
26
+ # Accepted for Sidekiq parity, then dropped: Sidekiq used `logger` for its
27
+ # own "connecting to Redis with options ..." line and `cluster_safe` to
28
+ # unlock `:nodes`. redis-client has no keyword for either.
29
+ IGNORED_KEYS = %i[logger cluster_safe].freeze
30
+
31
+ # Sidekiq-only spellings this module rewrites into redis-client keywords.
32
+ TRANSLATED_KEYS = %i[network_timeout master_name].freeze
33
+
34
+ # The umbrella socket timeout, under both its names. `network_timeout` is
35
+ # the redis-rb-era spelling every "widen the timeouts for a slow/remote
36
+ # Redis" snippet still uses; `timeout` is redis-client's own.
37
+ UMBRELLA_TIMEOUT_KEYS = %i[network_timeout timeout].freeze
38
+
39
+ # Wurk splits the socket timeouts (#101) and passes all three explicitly, and
40
+ # redis-client lets an explicit `read_timeout` win over `timeout` — so
41
+ # forwarding the umbrella verbatim would silently drop the host's value.
42
+ # Fan it out instead; a host-supplied split timeout still wins over the fan-out.
43
+ SPLIT_TIMEOUT_KEYS = %i[connect_timeout read_timeout write_timeout].freeze
44
+
45
+ # Symbols in redis-client, strings in plenty of YAML-sourced configs.
46
+ SYMBOLIZED_KEYS = %i[driver role].freeze
47
+
48
+ # Keys that mean something in Sidekiq but have no Wurk equivalent. Raise
49
+ # naming the key and its replacement — the alternative is an opaque
50
+ # `unknown keyword:` from three layers down inside a forked child.
51
+ REJECTED_KEYS = {
52
+ namespace: 'Redis namespacing was dropped in Sidekiq 7 and Wurk never implemented it ' \
53
+ '(docs/migrate-from-sidekiq.md §4). Give Wurk its own Redis database ' \
54
+ '(redis://host:6379/1) or its own instance instead.',
55
+ nodes: 'Wurk does not run on Redis Cluster. Point config.redis at a single server with ' \
56
+ '`url:`, or at a Sentinel set with `sentinels:`.'
57
+ }.freeze
58
+
59
+ # Keyword parameter kinds in Method#parameters.
60
+ KEYWORD_PARAMS = %i[key keyreq].freeze
61
+
62
+ class << self
63
+ # The keyword hash for RedisClient.config / RedisClient.sentinel.
64
+ # `defaults` are Wurk's own socket defaults; everything the host supplied
65
+ # wins over them.
66
+ def normalize(options, defaults: {})
67
+ opts = symbolize(options)
68
+ validate!(opts)
69
+ opts = translate(opts)
70
+
71
+ # A default `url` is meaningless next to a sentinel set and actively
72
+ # harmful: SentinelConfig derives the master name and db from it.
73
+ defaults = defaults.except(:url) if sentinel?(opts)
74
+
75
+ defaults.merge(split_timeouts(opts), opts.except(*UMBRELLA_TIMEOUT_KEYS))
76
+ end
77
+
78
+ # Raises for anything redis-client would reject. Cheap and pure, so it runs
79
+ # in the parent (Configuration#redis=) as well as at pool-build time.
80
+ def validate!(options)
81
+ opts = symbolize(options)
82
+
83
+ REJECTED_KEYS.each do |key, hint|
84
+ raise ArgumentError, "config.redis[:#{key}] is not supported. #{hint}" if opts.key?(key)
85
+ end
86
+
87
+ unknown = opts.keys - known_keys
88
+ return if unknown.empty?
89
+
90
+ raise ArgumentError,
91
+ "config.redis: unknown option#{'s' if unknown.size > 1} " \
92
+ "#{unknown.map(&:inspect).join(', ')}. Supported keys: #{known_keys.sort.join(', ')}."
93
+ end
94
+
95
+ # Sentinel sets go through RedisClient.sentinel — RedisClient.config
96
+ # rejects `sentinels:` outright. Same routing Sidekiq does.
97
+ def sentinel?(client_config)
98
+ client_config.key?(:sentinels)
99
+ end
100
+
101
+ # Every keyword redis-client itself accepts, read off its own signatures so
102
+ # the list can't drift from the installed version. Config#initialize takes
103
+ # a **kwargs rest and forwards to Config::Common, so both have to be walked;
104
+ # SentinelConfig adds the sentinel-only keys.
105
+ def known_keys
106
+ @known_keys ||= [
107
+ ::RedisClient::Config, ::RedisClient::Config::Common, ::RedisClient::SentinelConfig
108
+ ].flat_map { |mod| keyword_params(mod) }.union(POOL_KEYS, IGNORED_KEYS, TRANSLATED_KEYS)
109
+ end
110
+
111
+ private
112
+
113
+ # Drop what redis-client has no keyword for, then rewrite the Sidekiq
114
+ # spellings it does have an equivalent for.
115
+ def translate(opts)
116
+ opts = opts.except(*POOL_KEYS, *IGNORED_KEYS)
117
+ opts[:name] = opts.delete(:master_name) if opts.key?(:master_name)
118
+ SYMBOLIZED_KEYS.each { |key| opts[key] = opts[key].to_sym if opts[key] }
119
+ opts
120
+ end
121
+
122
+ def keyword_params(mod)
123
+ mod.instance_method(:initialize).parameters.filter_map do |kind, key|
124
+ key if KEYWORD_PARAMS.include?(kind)
125
+ end
126
+ end
127
+
128
+ # The umbrella timeout fanned out across the three split ones. Returns {}
129
+ # when the host set neither, leaving Wurk's defaults in place.
130
+ def split_timeouts(opts)
131
+ umbrella = opts.values_at(*UMBRELLA_TIMEOUT_KEYS).compact.first
132
+ return {} unless umbrella
133
+
134
+ SPLIT_TIMEOUT_KEYS.to_h { |key| [key, umbrella] }
135
+ end
136
+
137
+ def symbolize(options)
138
+ options.transform_keys(&:to_sym)
139
+ end
140
+ end
141
+ end
142
+ end