wurk 1.3.1 → 1.5.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 +4 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- 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 +45 -16
- data/lib/wurk/cli.rb +48 -14
- data/lib/wurk/client/buffered.rb +200 -43
- data/lib/wurk/client.rb +181 -38
- data/lib/wurk/compat.rb +1 -1
- data/lib/wurk/component.rb +36 -4
- data/lib/wurk/configuration.rb +47 -7
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/cron.rb +94 -37
- data/lib/wurk/deploy.rb +5 -3
- data/lib/wurk/embedded.rb +13 -0
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/reaper.rb +125 -58
- data/lib/wurk/fetcher/reliable.rb +366 -51
- data/lib/wurk/fetcher.rb +6 -0
- data/lib/wurk/heartbeat.rb +24 -12
- data/lib/wurk/history.rb +13 -1
- data/lib/wurk/job_logger.rb +16 -7
- data/lib/wurk/job_set.rb +3 -2
- data/lib/wurk/job_util.rb +44 -24
- data/lib/wurk/launcher.rb +230 -119
- data/lib/wurk/leader.rb +63 -14
- 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/logger.rb +1 -1
- data/lib/wurk/lua/loader.rb +9 -3
- data/lib/wurk/lua.rb +97 -14
- data/lib/wurk/manager.rb +29 -13
- data/lib/wurk/metrics/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +102 -32
- data/lib/wurk/metrics/queue_rollup.rb +13 -1
- data/lib/wurk/metrics/rollup.rb +13 -1
- data/lib/wurk/metrics/statsd.rb +32 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/interrupt_handler.rb +7 -6
- data/lib/wurk/middleware/poison_pill.rb +93 -31
- data/lib/wurk/middleware.rb +2 -2
- data/lib/wurk/pool_checkout.rb +39 -0
- data/lib/wurk/process_set.rb +10 -5
- data/lib/wurk/processor.rb +83 -9
- data/lib/wurk/profiler.rb +9 -4
- data/lib/wurk/queue.rb +18 -7
- data/lib/wurk/rails_boot.rb +38 -7
- data/lib/wurk/redis_client_adapter.rb +49 -5
- data/lib/wurk/redis_pool.rb +71 -25
- data/lib/wurk/scheduled.rb +30 -2
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +19 -10
- data/lib/wurk/swarm/child_boot.rb +36 -4
- data/lib/wurk/swarm.rb +258 -43
- data/lib/wurk/timer_loop.rb +14 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +11 -7
- 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 +12 -10
- data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
- data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
- data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
- data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
- data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +32 -27
- data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.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
|
@@ -19,15 +19,22 @@ module Wurk
|
|
|
19
19
|
# Counter key TTL is wire-compat with Sidekiq Pro — third-party tooling
|
|
20
20
|
# that watches `super_fetch:recovered:*` expects 72h.
|
|
21
21
|
#
|
|
22
|
-
# No server-middleware registration:
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
22
|
+
# No server-middleware registration: the producer is Reaper#drain, which
|
|
23
|
+
# drives the lifecycle directly via `track!(payload, queue:)` on each
|
|
24
|
+
# orphan it moves back to the public queue. The consumer is the ACK —
|
|
25
|
+
# `Fetcher::Reliable::UnitOfWork#acknowledge` pipelines {clear_in} next to
|
|
26
|
+
# its LREM, so an attempt that finished starts the next one at zero
|
|
27
|
+
# without costing a round trip of its own.
|
|
26
28
|
module PoisonPill
|
|
27
29
|
RECOVERY_THRESHOLD = 3
|
|
28
30
|
RECOVERY_TTL = 72 * 60 * 60
|
|
29
31
|
KEY_PREFIX = 'super_fetch:recovered:'
|
|
30
|
-
|
|
32
|
+
|
|
33
|
+
# Handed to death handlers (and therefore to `:death` batch callbacks)
|
|
34
|
+
# as the cause when a recovered job is killed as a poison pill. Never
|
|
35
|
+
# raised: nothing is on a stack here — the reaper kills the job from the
|
|
36
|
+
# outside, on behalf of the workers it took down.
|
|
37
|
+
class Poisoned < ::StandardError; end
|
|
31
38
|
|
|
32
39
|
# The `pill` handed to a Pro `super_fetch! { |jobstr, pill| }` recovery
|
|
33
40
|
# callback on the kill path. Responds to .jid/.klass/.count/.queue so a
|
|
@@ -62,7 +69,7 @@ module Wurk
|
|
|
62
69
|
|
|
63
70
|
jid = job['jid']
|
|
64
71
|
klass = job['class']
|
|
65
|
-
|
|
72
|
+
emit('jobs.recovered.fetch', klass, queue)
|
|
66
73
|
|
|
67
74
|
count = bump_counter(jid) if jid && !jid.empty?
|
|
68
75
|
if count && count >= RECOVERY_THRESHOLD
|
|
@@ -80,25 +87,74 @@ module Wurk
|
|
|
80
87
|
def recovery_count(jid)
|
|
81
88
|
return 0 if jid.nil? || jid.to_s.empty?
|
|
82
89
|
|
|
83
|
-
Wurk.redis { |conn| conn.call('GET',
|
|
90
|
+
Wurk.redis { |conn| conn.call('GET', counter_key(jid)) }.to_i
|
|
84
91
|
end
|
|
85
92
|
|
|
86
|
-
# Resets the counter for a jid
|
|
87
|
-
#
|
|
93
|
+
# Resets the counter for a jid. The hot path uses {clear_in} instead —
|
|
94
|
+
# this is the standalone form for the API/dashboard and for callers with
|
|
95
|
+
# no pipeline of their own.
|
|
88
96
|
def clear!(jid)
|
|
89
97
|
return if jid.nil? || jid.to_s.empty?
|
|
90
98
|
|
|
91
|
-
Wurk.redis { |conn| conn.call('DEL',
|
|
99
|
+
Wurk.redis { |conn| conn.call('DEL', counter_key(jid)) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Queue the counter reset onto a pipeline the caller already has open.
|
|
103
|
+
#
|
|
104
|
+
# The ACK is the reset point. An attempt that got as far as acking —
|
|
105
|
+
# returned, or raised and booked its retry — is proof the job did not
|
|
106
|
+
# take its worker down, and that is the only thing this counter
|
|
107
|
+
# measures. Without the reset, three reclaims caused by three unrelated
|
|
108
|
+
# crashes inside 72h dead-set a job that has been completing all along
|
|
109
|
+
# (jids do come back: a UI/API retry re-pushes the same one, as does any
|
|
110
|
+
# client that supplies its own). Riding a round trip the ACK already
|
|
111
|
+
# makes is what keeps that free for the jobs — nearly all of them — that
|
|
112
|
+
# were never reclaimed at all.
|
|
113
|
+
# Guards the blank jid for the same reason {clear!} and {recovery_count}
|
|
114
|
+
# do, rather than relying on the one caller to do it: `counter_key(nil)`
|
|
115
|
+
# is the bare KEY_PREFIX, so an unguarded DEL here would delete a key
|
|
116
|
+
# that is shared rather than per-job. The caller's own check stays --
|
|
117
|
+
# it also skips queueing the command at all -- but the invariant belongs
|
|
118
|
+
# on the method that builds the key.
|
|
119
|
+
def clear_in(pipe, jid)
|
|
120
|
+
return if jid.nil? || jid.to_s.empty?
|
|
121
|
+
|
|
122
|
+
pipe.call('DEL', counter_key(jid))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def counter_key(jid)
|
|
126
|
+
"#{KEY_PREFIX}#{jid}"
|
|
92
127
|
end
|
|
93
128
|
|
|
94
129
|
# Register a callback fired when a poison pill is detected. Callbacks
|
|
95
130
|
# receive a single Hash `{jid:, klass:, count:, queue:}` — matches
|
|
96
131
|
# Sidekiq Pro's documented shape so consumers can drop in unchanged.
|
|
132
|
+
#
|
|
133
|
+
# Dedups by callable identity — an initializer that re-runs (Rails
|
|
134
|
+
# code reloading, a re-required file) and hands back the *same* stored
|
|
135
|
+
# Proc each time won't accumulate duplicate closures (and the bindings
|
|
136
|
+
# they retain). A fresh block literal per call still registers
|
|
137
|
+
# separately, same as before. Returns a {Registration} the caller can
|
|
138
|
+
# `#remove!` to deregister explicitly.
|
|
97
139
|
def on_poison(&block)
|
|
98
140
|
raise ArgumentError, 'block required' unless block
|
|
99
141
|
|
|
100
|
-
callbacks << block
|
|
101
|
-
block
|
|
142
|
+
callbacks << block unless callbacks.any? { |cb| cb.equal?(block) }
|
|
143
|
+
Registration.new(callbacks, block)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Handle returned by {on_poison}. Removing it is idempotent and safe
|
|
147
|
+
# even after a {reset!} discarded the underlying list.
|
|
148
|
+
class Registration
|
|
149
|
+
def initialize(list, callback)
|
|
150
|
+
@list = list
|
|
151
|
+
@callback = callback
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def remove!
|
|
155
|
+
@list.delete(@callback)
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
102
158
|
end
|
|
103
159
|
|
|
104
160
|
def callbacks
|
|
@@ -113,19 +169,21 @@ module Wurk
|
|
|
113
169
|
# ---- internals --------------------------------------------------
|
|
114
170
|
|
|
115
171
|
def parse(payload)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
nil
|
|
123
|
-
end
|
|
124
|
-
end
|
|
172
|
+
return payload if payload.is_a?(::Hash)
|
|
173
|
+
return nil unless payload.is_a?(::String)
|
|
174
|
+
|
|
175
|
+
Wurk.load_json(payload)
|
|
176
|
+
rescue ::JSON::ParserError
|
|
177
|
+
nil
|
|
125
178
|
end
|
|
126
179
|
|
|
180
|
+
# No apply-safety claim: INCR is additive, so a block replayed after a
|
|
181
|
+
# lost reply bumps twice and can carry a healthy job past
|
|
182
|
+
# RECOVERY_THRESHOLD into the dead set. Raising instead only leaves the
|
|
183
|
+
# count short — Reaper#drain rescues, and the job is already back on its
|
|
184
|
+
# public queue, so it just misses one poison check.
|
|
127
185
|
def bump_counter(jid)
|
|
128
|
-
key =
|
|
186
|
+
key = counter_key(jid)
|
|
129
187
|
Wurk.redis do |conn|
|
|
130
188
|
count = conn.call('INCR', key).to_i
|
|
131
189
|
conn.call('EXPIRE', key, RECOVERY_TTL)
|
|
@@ -133,25 +191,29 @@ module Wurk
|
|
|
133
191
|
end
|
|
134
192
|
end
|
|
135
193
|
|
|
136
|
-
def
|
|
194
|
+
def emit(metric, klass, queue)
|
|
137
195
|
tags = []
|
|
138
196
|
tags << "class:#{klass}" if klass
|
|
139
197
|
tags << "queue:#{queue}" if queue
|
|
140
|
-
Wurk::Metrics::Statsd.increment(
|
|
198
|
+
Wurk::Metrics::Statsd.increment(metric, tags: tags.empty? ? nil : tags)
|
|
141
199
|
end
|
|
142
200
|
|
|
201
|
+
# Death handlers fire (`notify_failure` defaults to true). A poison kill
|
|
202
|
+
# is a death like any other exhaustion: suppressing it strands every
|
|
203
|
+
# batch that owns the job — Batch::DeathHandler is a death handler, so a
|
|
204
|
+
# silent kill leaves the batch's pending count stuck forever and neither
|
|
205
|
+
# `:death` nor `:complete` ever runs. Pro's spec is silent here
|
|
206
|
+
# (docs/target/sidekiq-pro.md §12); recorded in docs/idea/parity-divergences.md.
|
|
143
207
|
def mark_poison(payload, job, queue:, count:)
|
|
144
|
-
|
|
208
|
+
emit('jobs.poison', job['class'], queue)
|
|
145
209
|
json = payload.is_a?(String) ? payload : Wurk.dump_json(job)
|
|
146
|
-
Wurk::DeadSet.new.kill(json,
|
|
210
|
+
Wurk::DeadSet.new.kill(json, ex: poisoned_error(job['class'], count))
|
|
147
211
|
fire_callbacks(jid: job['jid'], klass: job['class'], count: count, queue: queue)
|
|
148
212
|
end
|
|
149
213
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
tags << "queue:#{queue}" if queue
|
|
154
|
-
Wurk::Metrics::Statsd.increment('jobs.poison', tags: tags.empty? ? nil : tags)
|
|
214
|
+
def poisoned_error(klass, count)
|
|
215
|
+
message = "#{klass || 'job'} was recovered #{count} times without completing"
|
|
216
|
+
Poisoned.new(message).tap { |e| e.set_backtrace(caller) }
|
|
155
217
|
end
|
|
156
218
|
|
|
157
219
|
def fire_callbacks(pill)
|
data/lib/wurk/middleware.rb
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'redis_pool'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
# The seam every `#redis`-style wrapper checks out through.
|
|
7
|
+
#
|
|
8
|
+
# `idempotent:` is a {Wurk::RedisPool} concept — it re-enables block replay
|
|
9
|
+
# after a command may already have applied server-side (see RedisPool#with).
|
|
10
|
+
# The object behind a wrapper is not always ours, though: the Sidekiq surface
|
|
11
|
+
# Wurk drops into lets a host supply any ConnectionPool-shaped object
|
|
12
|
+
# (`Sidekiq::Client#redis_pool=`, `Sidekiq::Web.redis_pool=`, the `pool:`
|
|
13
|
+
# kwarg on Stats::History / Deploy / Leader / Profiler / Metrics::History),
|
|
14
|
+
# and a bare `def with; yield conn; end` decorator — the shape people reach
|
|
15
|
+
# for to count or trace round trips — is a legal implementation of it. Handing
|
|
16
|
+
# that an unknown keyword is an ArgumentError, so the claim goes only to a pool
|
|
17
|
+
# that understands it.
|
|
18
|
+
#
|
|
19
|
+
# Dropping the claim is always sound: the pool falls back to the conservative
|
|
20
|
+
# no-replay default, which is exactly what a foreign pool did before the
|
|
21
|
+
# keyword existed. Apply-safety is an optimization, never a correctness input.
|
|
22
|
+
module PoolCheckout
|
|
23
|
+
def self.with(pool, idempotent, &)
|
|
24
|
+
return pool.with(&) unless idempotent && pool.is_a?(RedisPool)
|
|
25
|
+
|
|
26
|
+
pool.with(idempotent: true, &)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Same apply-safety forwarding as .with, minus the is_a? probe. Only for
|
|
30
|
+
# call sites whose pool is always this process's own {RedisPool} — never a
|
|
31
|
+
# host-supplied `pool:` kwarg — so the polymorphic check has nothing to
|
|
32
|
+
# decide.
|
|
33
|
+
def self.trusted(pool, idempotent, &)
|
|
34
|
+
return pool.with(&) unless idempotent
|
|
35
|
+
|
|
36
|
+
pool.with(idempotent: true, &)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
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
|
@@ -10,8 +10,11 @@ require_relative 'profiler'
|
|
|
10
10
|
module Wurk
|
|
11
11
|
# Inside each Manager, N Processors run in parallel. Each owns one thread,
|
|
12
12
|
# pulls a UnitOfWork from the capsule's fetcher, parses the payload, walks
|
|
13
|
-
# the server middleware chain, invokes `perform`, then ACKs (
|
|
14
|
-
# payload from the per-process private list).
|
|
13
|
+
# the server middleware chain, invokes `perform`, then ACKs (retires the
|
|
14
|
+
# payload from the per-process private list). The ACK is handed to the
|
|
15
|
+
# fetcher, which pipelines it with the next fetch rather than spending a
|
|
16
|
+
# round trip on it — #flush_acks covers the case where there is no next
|
|
17
|
+
# fetch.
|
|
15
18
|
#
|
|
16
19
|
# Shutdown is two-stage:
|
|
17
20
|
# * `terminate` flips a flag; the run loop exits between jobs.
|
|
@@ -23,6 +26,26 @@ module Wurk
|
|
|
23
26
|
class Processor
|
|
24
27
|
include Component
|
|
25
28
|
|
|
29
|
+
# Interrupt masks for the two `Thread.handle_interrupt` scopes every job
|
|
30
|
+
# runs inside. Frozen constants rather than the inline literals they
|
|
31
|
+
# replace: the mask never varies, and a literal allocated two Hashes per
|
|
32
|
+
# job. Sidekiq hoists the same pair (processor.rb:161-164).
|
|
33
|
+
IGNORE_SHUTDOWN_INTERRUPTS = { Wurk::Shutdown => :never }.freeze
|
|
34
|
+
private_constant :IGNORE_SHUTDOWN_INTERRUPTS
|
|
35
|
+
ALLOW_SHUTDOWN_INTERRUPTS = { Wurk::Shutdown => :immediate }.freeze
|
|
36
|
+
private_constant :ALLOW_SHUTDOWN_INTERRUPTS
|
|
37
|
+
|
|
38
|
+
# Stand-in for the default reloader, `proc { |&b| b.call }`. That default
|
|
39
|
+
# is an identity wrapper, but a block param (`|&b|`) forces MRI to reify
|
|
40
|
+
# the dispatch block into a Proc on every job just to call it straight
|
|
41
|
+
# back; `yield` does not. Same contract, one less allocation per job.
|
|
42
|
+
module IdentityReloader
|
|
43
|
+
def self.call
|
|
44
|
+
yield
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
private_constant :IdentityReloader
|
|
48
|
+
|
|
26
49
|
attr_reader :thread, :job, :capsule
|
|
27
50
|
|
|
28
51
|
def initialize(capsule, &callback)
|
|
@@ -32,7 +55,7 @@ module Wurk
|
|
|
32
55
|
@done = false
|
|
33
56
|
@job = nil
|
|
34
57
|
@thread = nil
|
|
35
|
-
@reloader = capsule.config[:reloader]
|
|
58
|
+
@reloader = resolve_reloader(capsule.config[:reloader])
|
|
36
59
|
@job_logger = (capsule.config[:job_logger] || JobLogger).new(capsule.config)
|
|
37
60
|
@retrier = JobRetry.new(capsule)
|
|
38
61
|
end
|
|
@@ -116,6 +139,19 @@ module Wurk
|
|
|
116
139
|
@lock.synchronize { @work.delete(tid) }
|
|
117
140
|
end
|
|
118
141
|
|
|
142
|
+
# RAII: publish for the duration of the block, always retract. The write
|
|
143
|
+
# lives *inside* this method's ensure frame on purpose — with the `set`
|
|
144
|
+
# one line above a `begin`, an async raise landing in that gap (a host
|
|
145
|
+
# timeout's `Thread#raise`, say) escapes the ensure and strands the
|
|
146
|
+
# entry for the life of the process: the payload String stays reachable
|
|
147
|
+
# and every heartbeat reports the thread as busy.
|
|
148
|
+
def track(tid, hash)
|
|
149
|
+
set(tid, hash)
|
|
150
|
+
yield
|
|
151
|
+
ensure
|
|
152
|
+
delete(tid)
|
|
153
|
+
end
|
|
154
|
+
|
|
119
155
|
def dup
|
|
120
156
|
@lock.synchronize { @work.dup }
|
|
121
157
|
end
|
|
@@ -136,8 +172,23 @@ module Wurk
|
|
|
136
172
|
|
|
137
173
|
private
|
|
138
174
|
|
|
175
|
+
# Only the untouched framework default is swapped out — a host-supplied
|
|
176
|
+
# reloader (Rails wraps every job in `Rails.application.reloader`) is used
|
|
177
|
+
# exactly as given. `equal?` against DEFAULTS is sound because
|
|
178
|
+
# Configuration's deep-dup copies Hashes/Arrays only, so an unset
|
|
179
|
+
# `:reloader` is still the very Proc object DEFAULTS holds.
|
|
180
|
+
def resolve_reloader(configured)
|
|
181
|
+
return IdentityReloader if configured.nil? || configured.equal?(Configuration::DEFAULTS[:reloader])
|
|
182
|
+
|
|
183
|
+
configured
|
|
184
|
+
end
|
|
185
|
+
|
|
139
186
|
def run
|
|
140
|
-
|
|
187
|
+
begin
|
|
188
|
+
process_one until @done
|
|
189
|
+
ensure
|
|
190
|
+
flush_acks
|
|
191
|
+
end
|
|
141
192
|
@callback&.call(self)
|
|
142
193
|
rescue Wurk::Shutdown
|
|
143
194
|
@callback&.call(self)
|
|
@@ -147,6 +198,24 @@ module Wurk
|
|
|
147
198
|
raise
|
|
148
199
|
end
|
|
149
200
|
|
|
201
|
+
# The fetcher holds each finished job's LREM until a fetch can pipeline it
|
|
202
|
+
# (Fetcher::Reliable#defer_ack). This thread has stopped fetching, so
|
|
203
|
+
# nothing else will send the one it may still be holding — and a finished
|
|
204
|
+
# job left in the private list is invisible to Manager#hard_shutdown's
|
|
205
|
+
# in-flight list, so the next boot's reaper would run it a second time.
|
|
206
|
+
#
|
|
207
|
+
# Inside the loop's own ensure rather than the method's: the callback below
|
|
208
|
+
# drops this Processor from the Manager's pool, which is what lets
|
|
209
|
+
# Manager#stop return and close the capsule's Redis pool out from under us.
|
|
210
|
+
# `respond_to?` because a config[:fetch_class] fetcher need not defer, and
|
|
211
|
+
# the capsule has no fetcher at all if the launcher died before prepare!.
|
|
212
|
+
def flush_acks
|
|
213
|
+
fetcher = @capsule.fetcher
|
|
214
|
+
fetcher.flush_pending_acks if fetcher.respond_to?(:flush_pending_acks)
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
handle_exception(e, { context: 'Error flushing pending acks' })
|
|
217
|
+
end
|
|
218
|
+
|
|
150
219
|
def fetch
|
|
151
220
|
@capsule.fetcher.retrieve_work
|
|
152
221
|
rescue Wurk::Shutdown
|
|
@@ -164,11 +233,17 @@ module Wurk
|
|
|
164
233
|
job_hash = parse_or_kill(jobstr, uow)
|
|
165
234
|
return if job_hash.nil?
|
|
166
235
|
|
|
236
|
+
# The fetcher never parses, so hand it the jid we just read: the ACK
|
|
237
|
+
# retires this job's poison-pill recovery counter inside the round trip
|
|
238
|
+
# it rides. A fetcher plugged in via `config[:fetch_class]` has no jid
|
|
239
|
+
# slot and simply ACKs — the counter then ages out on its 72h TTL.
|
|
240
|
+
uow.jid = job_hash['jid'] if uow.respond_to?(:jid=)
|
|
241
|
+
|
|
167
242
|
ack = false
|
|
168
243
|
begin
|
|
169
|
-
Thread.handle_interrupt(
|
|
244
|
+
Thread.handle_interrupt(IGNORE_SHUTDOWN_INTERRUPTS) do
|
|
170
245
|
dispatch(job_hash, queue, jobstr) do |instance|
|
|
171
|
-
Thread.handle_interrupt(
|
|
246
|
+
Thread.handle_interrupt(ALLOW_SHUTDOWN_INTERRUPTS) do
|
|
172
247
|
execute_job(instance, job_hash, queue)
|
|
173
248
|
end
|
|
174
249
|
end
|
|
@@ -241,15 +316,14 @@ module Wurk
|
|
|
241
316
|
# Heartbeat can mirror it into Redis (`<identity>:work`), and increments
|
|
242
317
|
# PROCESSED/FAILURE counters around the inner block.
|
|
243
318
|
def stats(jobstr, queue)
|
|
244
|
-
|
|
245
|
-
|
|
319
|
+
run_at = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second)
|
|
320
|
+
WORK_STATE.track(tid, queue: queue, payload: jobstr, run_at: run_at) do
|
|
246
321
|
yield
|
|
247
322
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
248
323
|
FAILURE.incr
|
|
249
324
|
raise
|
|
250
325
|
ensure
|
|
251
326
|
PROCESSED.incr
|
|
252
|
-
WORK_STATE.delete(tid)
|
|
253
327
|
end
|
|
254
328
|
end
|
|
255
329
|
end
|
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`
|
|
@@ -33,11 +34,15 @@ module Wurk
|
|
|
33
34
|
# from the interrupt/expiry middleware) must propagate untouched so the
|
|
34
35
|
# retry/skip flow works. Only the storage step is made failure-safe
|
|
35
36
|
# (see #safe_store).
|
|
36
|
-
|
|
37
|
+
# No `&block` parameter: every job passes through here, and declaring one
|
|
38
|
+
# would reify the caller's block into a Proc even on the `yield`-straight-
|
|
39
|
+
# through path that opted-out jobs take. The capture path pays for its own
|
|
40
|
+
# block instead.
|
|
41
|
+
def call(job_hash)
|
|
37
42
|
label = job_hash['profile']
|
|
38
43
|
return yield unless label && defined?(::Vernier)
|
|
39
44
|
|
|
40
|
-
capture(job_hash, label
|
|
45
|
+
capture(job_hash, label) { yield } # rubocop:disable Style/ExplicitBlockArgument
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
# Persists a profile. Extracted from capture so it is unit-testable
|
|
@@ -113,8 +118,8 @@ module Wurk
|
|
|
113
118
|
end
|
|
114
119
|
end
|
|
115
120
|
|
|
116
|
-
def with_pool(pool, &)
|
|
117
|
-
pool ?
|
|
121
|
+
def with_pool(pool, idempotent: false, &)
|
|
122
|
+
pool ? PoolCheckout.with(pool, idempotent, &) : Wurk.redis(idempotent:, &)
|
|
118
123
|
end
|
|
119
124
|
|
|
120
125
|
def now
|
data/lib/wurk/queue.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'fetcher/reliable'
|
|
3
4
|
require_relative 'job_record'
|
|
4
5
|
|
|
5
6
|
module Wurk
|
|
@@ -12,6 +13,11 @@ module Wurk
|
|
|
12
13
|
# (see `Wurk::Fetcher::Reliable#queues_cmd`). In-flight jobs continue to
|
|
13
14
|
# completion — pausing only stops new fetches.
|
|
14
15
|
#
|
|
16
|
+
# Fetchers answer from a `PAUSED_TTL` cache rather than re-reading the SET
|
|
17
|
+
# every pass, so `pause!`/`unpause!` expire this process's copy inline and
|
|
18
|
+
# other processes pick the change up within the TTL. `paused?` is a reporting
|
|
19
|
+
# call and always reads Redis.
|
|
20
|
+
#
|
|
15
21
|
# Spec: docs/target/sidekiq-free.md §19.2, sidekiq-pro.md §6.
|
|
16
22
|
class Queue
|
|
17
23
|
include Enumerable
|
|
@@ -25,7 +31,7 @@ module Wurk
|
|
|
25
31
|
|
|
26
32
|
# @return [Array<Queue>] one per known queue, sorted by name.
|
|
27
33
|
def self.all
|
|
28
|
-
names = Wurk.redis { |conn| conn.call('SMEMBERS', Keys::QUEUES_SET) }
|
|
34
|
+
names = Wurk.redis(idempotent: true) { |conn| conn.call('SMEMBERS', Keys::QUEUES_SET) }
|
|
29
35
|
names.sort.map { |n| new(n) }
|
|
30
36
|
end
|
|
31
37
|
|
|
@@ -35,12 +41,12 @@ module Wurk
|
|
|
35
41
|
end
|
|
36
42
|
|
|
37
43
|
def size
|
|
38
|
-
Wurk.redis { |conn| conn.call('LLEN', @rname) }
|
|
44
|
+
Wurk.redis(idempotent: true) { |conn| conn.call('LLEN', @rname) }
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
# Seconds since the oldest job (tail of LIST) was enqueued. 0.0 when empty.
|
|
42
48
|
def latency
|
|
43
|
-
payload = Wurk.redis { |conn| conn.call('LRANGE', @rname, -1, -1).first }
|
|
49
|
+
payload = Wurk.redis(idempotent: true) { |conn| conn.call('LRANGE', @rname, -1, -1).first }
|
|
44
50
|
return 0.0 if payload.nil?
|
|
45
51
|
|
|
46
52
|
JobRecord.latency_from(Wurk.load_json(payload)['enqueued_at'])
|
|
@@ -51,19 +57,21 @@ module Wurk
|
|
|
51
57
|
# True iff this queue's name is a member of the `paused` SET. Wurk
|
|
52
58
|
# implements the Pro contract for free; fetchers consult the same set.
|
|
53
59
|
def paused?
|
|
54
|
-
Wurk.redis { |conn| conn.call('SISMEMBER', Keys::PAUSED_SET, @name) } == 1
|
|
60
|
+
Wurk.redis(idempotent: true) { |conn| conn.call('SISMEMBER', Keys::PAUSED_SET, @name) } == 1
|
|
55
61
|
end
|
|
56
62
|
|
|
57
63
|
# Pause new fetches against this queue. Idempotent — `SADD` returns
|
|
58
64
|
# 0 when the name was already present. In-flight jobs are untouched.
|
|
59
65
|
def pause! # rubocop:disable Naming/PredicateMethod
|
|
60
|
-
Wurk.redis { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
|
|
66
|
+
Wurk.redis(idempotent: true) { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
|
|
67
|
+
Fetcher::Reliable.invalidate_paused_cache!
|
|
61
68
|
true
|
|
62
69
|
end
|
|
63
70
|
|
|
64
71
|
# Resume fetches. Idempotent.
|
|
65
72
|
def unpause! # rubocop:disable Naming/PredicateMethod
|
|
66
|
-
Wurk.redis { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
|
|
73
|
+
Wurk.redis(idempotent: true) { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
|
|
74
|
+
Fetcher::Reliable.invalidate_paused_cache!
|
|
67
75
|
true
|
|
68
76
|
end
|
|
69
77
|
|
|
@@ -74,7 +82,7 @@ module Wurk
|
|
|
74
82
|
loop do
|
|
75
83
|
start = page * PAGE_SIZE
|
|
76
84
|
stop = start + PAGE_SIZE - 1
|
|
77
|
-
slice = Wurk.redis { |conn| conn.call('LRANGE', @rname, start, stop) }
|
|
85
|
+
slice = Wurk.redis(idempotent: true) { |conn| conn.call('LRANGE', @rname, start, stop) }
|
|
78
86
|
slice.each { |value| yield JobRecord.new(value, @name) }
|
|
79
87
|
break if slice.size < PAGE_SIZE
|
|
80
88
|
|
|
@@ -91,6 +99,9 @@ module Wurk
|
|
|
91
99
|
# UNLINK the list + drop the queue from the `queues` set. Pipelined
|
|
92
100
|
# so a partial failure leaves at most one of the two ops applied.
|
|
93
101
|
# Method name is Sidekiq wire-compat — `clear?` would break the alias.
|
|
102
|
+
#
|
|
103
|
+
# Unlike pause!/unpause!, this one can't claim apply-safety: a replay after
|
|
104
|
+
# a lost reply would UNLINK whatever a producer enqueued in between.
|
|
94
105
|
def clear # rubocop:disable Naming/PredicateMethod
|
|
95
106
|
Wurk.redis do |conn|
|
|
96
107
|
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
|
|