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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/wurk/batch/callbacks.rb +82 -12
- data/lib/wurk/batch/death_handler.rb +7 -4
- data/lib/wurk/batch/server_middleware.rb +1 -1
- data/lib/wurk/batch.rb +121 -15
- data/lib/wurk/capsule.rb +5 -4
- data/lib/wurk/cli.rb +48 -14
- data/lib/wurk/client/buffered.rb +193 -43
- data/lib/wurk/client.rb +87 -14
- data/lib/wurk/compat.rb +1 -1
- data/lib/wurk/component.rb +2 -2
- data/lib/wurk/configuration.rb +10 -2
- data/lib/wurk/cron.rb +94 -37
- data/lib/wurk/deploy.rb +5 -3
- data/lib/wurk/embedded.rb +13 -0
- data/lib/wurk/fetcher/reaper.rb +113 -56
- data/lib/wurk/fetcher/reliable.rb +62 -9
- data/lib/wurk/heartbeat.rb +22 -10
- data/lib/wurk/history.rb +13 -1
- data/lib/wurk/launcher.rb +133 -66
- data/lib/wurk/leader.rb +29 -10
- data/lib/wurk/limiter/base.rb +8 -10
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/concurrent.rb +27 -22
- data/lib/wurk/limiter/window.rb +13 -11
- data/lib/wurk/limiter.rb +7 -4
- data/lib/wurk/lua.rb +97 -14
- data/lib/wurk/manager.rb +29 -13
- data/lib/wurk/metrics/history.rb +4 -3
- data/lib/wurk/metrics/queue_rollup.rb +13 -1
- data/lib/wurk/metrics/rollup.rb +13 -1
- data/lib/wurk/middleware/interrupt_handler.rb +7 -6
- data/lib/wurk/middleware/poison_pill.rb +70 -29
- data/lib/wurk/middleware.rb +2 -2
- data/lib/wurk/pool_checkout.rb +29 -0
- data/lib/wurk/process_set.rb +10 -5
- data/lib/wurk/processor.rb +6 -0
- data/lib/wurk/profiler.rb +3 -2
- data/lib/wurk/queue.rb +10 -7
- data/lib/wurk/rails_boot.rb +38 -7
- data/lib/wurk/redis_client_adapter.rb +48 -4
- data/lib/wurk/redis_options.rb +142 -0
- data/lib/wurk/redis_pool.rb +102 -39
- data/lib/wurk/scheduled.rb +30 -2
- data/lib/wurk/stats.rb +14 -9
- data/lib/wurk/swarm/child_boot.rb +12 -0
- data/lib/wurk/swarm.rb +174 -33
- data/lib/wurk/timer_loop.rb +14 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/enterprise.rb +58 -6
- data/lib/wurk/web/extension.rb +1 -1
- data/lib/wurk/web/search.rb +5 -3
- data/lib/wurk.rb +53 -2
- data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-OmC5NPgw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-CIpai7St.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-A_kwSR6Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-BG7HTqlp.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dashboard-B9rOrkzk.js → Dashboard-A_ToqHoo.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-8J21jMyK.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +1 -0
- data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-Fh_Ae7UW.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-Ceng0PMB.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-CruDWvNZ.js} +1 -1
- data/vendor/assets/dashboard/assets/{Metrics-BBTDxcaE.js → Metrics-CIT7VCoN.js} +1 -1
- data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +1 -0
- data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-D86FYohJ.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-Bz1O1D-i.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-B6h2akTu.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-OOu22e5s.js} +1 -1
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
- data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +141 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-dVPGEWzn.js} +1 -1
- data/vendor/assets/dashboard/index.html +2 -2
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +22 -20
- data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
- data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
- data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
data/lib/wurk/process_set.rb
CHANGED
|
@@ -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
|
data/lib/wurk/processor.rb
CHANGED
|
@@ -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 ?
|
|
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|
|
data/lib/wurk/rails_boot.rb
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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 =
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|