wurk 1.4.0 → 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 +3 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- data/lib/wurk/capsule.rb +40 -12
- data/lib/wurk/client/buffered.rb +7 -0
- data/lib/wurk/client.rb +102 -32
- data/lib/wurk/component.rb +34 -2
- data/lib/wurk/configuration.rb +45 -6
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/reaper.rb +12 -2
- data/lib/wurk/fetcher/reliable.rb +322 -60
- data/lib/wurk/fetcher.rb +6 -0
- data/lib/wurk/heartbeat.rb +2 -2
- 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 +116 -72
- data/lib/wurk/leader.rb +36 -6
- data/lib/wurk/logger.rb +1 -1
- data/lib/wurk/lua/loader.rb +9 -3
- data/lib/wurk/metrics/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +98 -29
- data/lib/wurk/metrics/statsd.rb +32 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/poison_pill.rb +23 -2
- data/lib/wurk/pool_checkout.rb +10 -0
- data/lib/wurk/processor.rb +79 -11
- data/lib/wurk/profiler.rb +6 -2
- data/lib/wurk/queue.rb +8 -0
- data/lib/wurk/redis_client_adapter.rb +1 -1
- data/lib/wurk/redis_pool.rb +2 -1
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +5 -1
- data/lib/wurk/swarm/child_boot.rb +24 -4
- data/lib/wurk/swarm.rb +88 -14
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +11 -7
- data/lib/wurk.rb +2 -8
- data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-OmC5NPgw.js → BatchDetail-CUXJUQ3Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-CIpai7St.js → Batches-Cxan6Ngw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-A_kwSR6Q.js → Busy-DC5EGM0g.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-BG7HTqlp.js → Cron-Dlt8tXJA.js} +1 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
- data/vendor/assets/dashboard/assets/{Dead-8J21jMyK.js → Dead-dZ7VGlKS.js} +1 -1
- data/vendor/assets/dashboard/assets/{Extension-B4Q9FIQu.js → Extension-DaFpEIJf.js} +1 -1
- data/vendor/assets/dashboard/assets/{FilterBox-Fh_Ae7UW.js → FilterBox-CO3aYWIq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-Ceng0PMB.js → JobDetailModal-DSWbT6G0.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-CruDWvNZ.js → Limiters-Cb4PKXNR.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
- data/vendor/assets/dashboard/assets/{Modal-CN3rdKA_.js → Modal-B86q6ruL.js} +1 -1
- 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-D86FYohJ.js → Queues-CIyPevOy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-Bz1O1D-i.js → Retries-DopwXkXl.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-B6h2akTu.js → Scheduled-1-Z7i1zE.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-OOu22e5s.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/{useResetPageOnEmpty-dVPGEWzn.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 +2 -2
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +30 -26
- data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
- data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +0 -141
data/lib/wurk/metrics/history.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../middleware'
|
|
4
4
|
require_relative '../pool_checkout'
|
|
5
|
+
require_relative 'accumulator'
|
|
5
6
|
|
|
6
7
|
module Wurk
|
|
7
8
|
module Metrics
|
|
@@ -31,9 +32,10 @@ module Wurk
|
|
|
31
32
|
# running we keep its bucket around for the retention window measured from
|
|
32
33
|
# *last write*, not from first write. So we EXPIRE unconditionally.
|
|
33
34
|
#
|
|
34
|
-
# The middleware is hot-path — every
|
|
35
|
-
#
|
|
36
|
-
#
|
|
35
|
+
# The middleware is hot-path — every job pays for it — so it does not touch
|
|
36
|
+
# Redis at all. Each execution folds into a process-wide Accumulator and
|
|
37
|
+
# Wurk::Metrics::Flusher writes the whole tree out every FLUSH_INTERVAL, in
|
|
38
|
+
# the same 6-commands-per-(class, minute) shape the per-job pipeline sent.
|
|
37
39
|
class History
|
|
38
40
|
include Wurk::Middleware::ServerMiddleware
|
|
39
41
|
|
|
@@ -44,6 +46,22 @@ module Wurk
|
|
|
44
46
|
MINUTE_KEY_PREFIX = 'j|'
|
|
45
47
|
DATE_FORMAT = '%Y%m%d' # YYYYMMDD — matches Sidekiq's j| key
|
|
46
48
|
|
|
49
|
+
# Ceiling on how stale an unflushed counter can be, in seconds. A
|
|
50
|
+
# constant, never a config knob: it exists to bound the dashboard's lag,
|
|
51
|
+
# not to be tuned. Sidekiq — the engine these numbers get compared
|
|
52
|
+
# against — flushes its own process stats on a 10s heartbeat and writes
|
|
53
|
+
# nothing per job, so this is the tighter of the two. Signed off in
|
|
54
|
+
# docs/plans/2026/08/06/101-faster-than-sidekiq/00-semantics-signoff.md §2.
|
|
55
|
+
FLUSH_INTERVAL = 5
|
|
56
|
+
|
|
57
|
+
# Process-wide, like Processor::PROCESSED — the counters belong to the
|
|
58
|
+
# process, not to a middleware instance (the chain builds one per capsule)
|
|
59
|
+
# and not to a job.
|
|
60
|
+
ACCUMULATOR = Accumulator.new
|
|
61
|
+
|
|
62
|
+
# Hourly buckets are already class-scoped, so their fields are bare.
|
|
63
|
+
HOUR_FIELDS = %w[p f ms].freeze
|
|
64
|
+
|
|
47
65
|
def call(_worker, job, _queue)
|
|
48
66
|
klass = job['class']
|
|
49
67
|
started = monotonic_ms
|
|
@@ -54,8 +72,11 @@ module Wurk
|
|
|
54
72
|
result
|
|
55
73
|
ensure
|
|
56
74
|
duration = (monotonic_ms - started).round
|
|
57
|
-
# Best-effort: a metrics
|
|
58
|
-
#
|
|
75
|
+
# Best-effort: a metrics failure must never propagate into the job
|
|
76
|
+
# result. The processor already finalized the ack path. Recording is
|
|
77
|
+
# in-memory now, so what this catches is a bad payload (a `class` that
|
|
78
|
+
# is not a String) rather than a Redis blip — the Redis half moved to
|
|
79
|
+
# Flusher, which reports on its own thread.
|
|
59
80
|
begin
|
|
60
81
|
self.class.record(klass, duration, success: success, redis_pool: redis_pool)
|
|
61
82
|
rescue StandardError => e
|
|
@@ -65,39 +86,46 @@ module Wurk
|
|
|
65
86
|
end
|
|
66
87
|
|
|
67
88
|
class << self
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
89
|
+
# No Redis. `success: true` → `<klass>|p`; `success: false` →
|
|
90
|
+
# `<klass>|f`. `<klass>|ms` accumulates total runtime in milliseconds
|
|
91
|
+
# for *both* outcomes (so an operator can ask "how much wall-clock time
|
|
92
|
+
# has FooJob consumed?" without branching on outcome).
|
|
93
|
+
#
|
|
94
|
+
# `at:` defaults to nil rather than `::Time.now`: this runs once per job
|
|
95
|
+
# and the bucket only needs whole UTC minutes, which #minute_bucket
|
|
96
|
+
# reads straight off the clock as an Integer.
|
|
97
|
+
def record(klass, duration_ms, success:, redis_pool: nil, at: nil)
|
|
74
98
|
return if klass.nil? || klass.empty?
|
|
75
99
|
|
|
76
100
|
ms = duration_ms.to_i
|
|
77
101
|
ms = 0 if ms.negative?
|
|
78
|
-
|
|
79
|
-
with_pool(redis_pool) { |conn| pipeline_write(conn, klass, ms, success, buckets) }
|
|
102
|
+
ACCUMULATOR.add(redis_pool, klass, minute_bucket(at), ms, success)
|
|
80
103
|
nil
|
|
81
104
|
end
|
|
82
105
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
# Drains an accumulator into Redis, one pipeline per pool. Raises the
|
|
107
|
+
# first pool's failure after every other pool has had its turn —
|
|
108
|
+
# Wurk::Metrics::Flusher owns turning that into an error-handler call.
|
|
109
|
+
#
|
|
110
|
+
# Only the *failed* pool's counts are merged back. Putting the whole
|
|
111
|
+
# drained tree back would re-send the writes that already landed, and
|
|
112
|
+
# HINCRBY would count them twice.
|
|
113
|
+
#
|
|
114
|
+
# The argument is a collaborator, not an option: a process has exactly
|
|
115
|
+
# one accumulator and nothing in wurk passes a second. Tests pass their
|
|
116
|
+
# own so a deliberately broken pool cannot leak into a parallel test's
|
|
117
|
+
# flush through the process-wide one.
|
|
118
|
+
def flush(accumulator = ACCUMULATOR)
|
|
119
|
+
error = nil
|
|
120
|
+
accumulator.drain.each do |pool, minutes|
|
|
121
|
+
write(pool, minutes)
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
accumulator.merge_back(pool, minutes)
|
|
124
|
+
error = e
|
|
93
125
|
end
|
|
94
|
-
|
|
126
|
+
raise error if error
|
|
95
127
|
|
|
96
|
-
|
|
97
|
-
outcome_field, ms_field, ms, ttl = parts
|
|
98
|
-
pipe.call('HINCRBY', key, outcome_field, 1)
|
|
99
|
-
pipe.call('HINCRBY', key, ms_field, ms)
|
|
100
|
-
pipe.call('EXPIRE', key, ttl)
|
|
128
|
+
nil
|
|
101
129
|
end
|
|
102
130
|
|
|
103
131
|
# Public formatters — Wurk::Metrics::Query reuses these so the two
|
|
@@ -112,6 +140,47 @@ module Wurk
|
|
|
112
140
|
t = time.utc
|
|
113
141
|
"#{klass}-#{t.strftime(DATE_FORMAT)}-#{t.hour}"
|
|
114
142
|
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# Whole UTC minutes since the epoch. Integer arithmetic on a raw clock
|
|
147
|
+
# read, so nothing is allocated per job.
|
|
148
|
+
def minute_bucket(at)
|
|
149
|
+
return ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second) / 60 unless at
|
|
150
|
+
|
|
151
|
+
at.to_i / 60
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def write(pool, minutes)
|
|
155
|
+
with_pool(pool) do |conn|
|
|
156
|
+
conn.pipelined do |pipe|
|
|
157
|
+
minutes.each do |minute, classes|
|
|
158
|
+
at = ::Time.at(minute * 60).utc
|
|
159
|
+
key = minute_key(at)
|
|
160
|
+
classes.each { |klass, counts| write_class(pipe, key, at, klass, counts) }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def write_class(pipe, bucket_key, at, klass, counts)
|
|
167
|
+
incr_bucket(pipe, bucket_key, ["#{klass}|p", "#{klass}|f", "#{klass}|ms"], counts)
|
|
168
|
+
incr_bucket(pipe, hour_key(klass, at), HOUR_FIELDS, counts)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# `p`/`f` are emitted only when non-zero, so a bucket ends up with the
|
|
172
|
+
# exact field set the per-job path produced: `HINCRBY <field> 0` would
|
|
173
|
+
# materialize a counter no unbatched write ever created, and the read
|
|
174
|
+
# side would start reporting a `f: 0` series for classes that have never
|
|
175
|
+
# failed. `ms` is unconditional — the per-job path always incremented
|
|
176
|
+
# it, including by zero.
|
|
177
|
+
def incr_bucket(pipe, key, fields, counts)
|
|
178
|
+
processed, failed, ms = counts
|
|
179
|
+
pipe.call('HINCRBY', key, fields[0], processed) if processed.positive?
|
|
180
|
+
pipe.call('HINCRBY', key, fields[1], failed) if failed.positive?
|
|
181
|
+
pipe.call('HINCRBY', key, fields[2], ms)
|
|
182
|
+
pipe.call('EXPIRE', key, MID_TERM)
|
|
183
|
+
end
|
|
115
184
|
end
|
|
116
185
|
|
|
117
186
|
private
|
data/lib/wurk/metrics/statsd.rb
CHANGED
|
@@ -35,6 +35,15 @@ module Wurk
|
|
|
35
35
|
METRIC_PREFIX = 'sidekiq.'
|
|
36
36
|
DEFAULT_SAMPLE_RATE = 1.0
|
|
37
37
|
|
|
38
|
+
# Unresolved state of the {.client} memo, distinct from a resolved nil.
|
|
39
|
+
UNSET = :unset
|
|
40
|
+
private_constant :UNSET
|
|
41
|
+
|
|
42
|
+
# Seeded in the class body (not `class << self`, where `self` is the
|
|
43
|
+
# singleton class) so {.client} is a bare ivar read with no `defined?`
|
|
44
|
+
# guard on the hot path.
|
|
45
|
+
@client = UNSET
|
|
46
|
+
|
|
38
47
|
class << self
|
|
39
48
|
attr_accessor :options
|
|
40
49
|
|
|
@@ -91,19 +100,35 @@ module Wurk
|
|
|
91
100
|
# Resolves the live client: invokes the configured `dogstatsd` proc
|
|
92
101
|
# exactly once per process and memoizes. Returns nil when no proc
|
|
93
102
|
# is configured, so callers get a clean no-op without raising.
|
|
103
|
+
#
|
|
104
|
+
# The *unconfigured* answer is memoized too, which is what makes the
|
|
105
|
+
# no-client path free: Client#emit_enqueued asks once per payload, so
|
|
106
|
+
# re-reading `Wurk.configuration` here would cost a config lookup per
|
|
107
|
+
# job on every bulk push. Both invalidation points call {.reset!}.
|
|
94
108
|
def client
|
|
95
|
-
|
|
109
|
+
memo = @client
|
|
110
|
+
return memo unless UNSET.equal?(memo)
|
|
96
111
|
|
|
97
112
|
builder = Wurk.configuration.respond_to?(:dogstatsd) ? Wurk.configuration.dogstatsd : nil
|
|
98
|
-
return nil if builder.nil?
|
|
99
|
-
|
|
100
113
|
@client = builder.respond_to?(:call) ? builder.call : builder
|
|
101
114
|
end
|
|
102
115
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
116
|
+
# {.client} without the raise: a builder proc that blows up is reported
|
|
117
|
+
# through the error handler and treated as "no client", so misconfigured
|
|
118
|
+
# metrics can never fail the work they were measuring. Callers on a
|
|
119
|
+
# hot path use this to skip building anything the emit would drop.
|
|
120
|
+
def safe_client
|
|
121
|
+
client
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
handle_error(e)
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Test/lifecycle hook. Reset between specs, after fork so the parent's
|
|
128
|
+
# socket doesn't bleed into children, and on every `config.dogstatsd=`
|
|
129
|
+
# — a memoized nil would otherwise hide a client configured later.
|
|
105
130
|
def reset!
|
|
106
|
-
@client =
|
|
131
|
+
@client = UNSET
|
|
107
132
|
end
|
|
108
133
|
|
|
109
134
|
private
|
|
@@ -118,8 +143,7 @@ module Wurk
|
|
|
118
143
|
end
|
|
119
144
|
|
|
120
145
|
def call(_worker, job, queue) # rubocop:disable Metrics/AbcSize
|
|
121
|
-
|
|
122
|
-
return yield if client.nil?
|
|
146
|
+
return yield if self.class.safe_client.nil?
|
|
123
147
|
|
|
124
148
|
klass = job['class']
|
|
125
149
|
opts = per_job_options(klass, job, queue)
|
|
@@ -146,16 +170,6 @@ module Wurk
|
|
|
146
170
|
|
|
147
171
|
private
|
|
148
172
|
|
|
149
|
-
# Wraps `self.class.client` so a misconfigured builder proc never turns
|
|
150
|
-
# a metrics-init failure into a job failure. Returns nil on error (the
|
|
151
|
-
# caller falls through to a plain `yield`).
|
|
152
|
-
def safe_client
|
|
153
|
-
self.class.client
|
|
154
|
-
rescue StandardError => e
|
|
155
|
-
self.class.send(:handle_error, e)
|
|
156
|
-
nil
|
|
157
|
-
end
|
|
158
|
-
|
|
159
173
|
# Per-spec §9.2: caller-supplied proc may override tags / sample_rate
|
|
160
174
|
# on a per-job basis. The job's own `dd_rate` field, when present,
|
|
161
175
|
# always wins — it's the per-push override hinted in §8.
|
|
@@ -7,9 +7,10 @@ module Wurk
|
|
|
7
7
|
# pushes to index 0, `insert_before`/`insert_after` anchor relative to an
|
|
8
8
|
# existing entry, `invoke` walks the chain handing the inner block to each.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# Entries store klass + ctor args, not the live object: every job gets a
|
|
11
|
+
# fresh instance per entry (`invoke` builds each as it reaches it, `retrieve`
|
|
12
|
+
# builds them all up front). This keeps middleware thread-safe by
|
|
13
|
+
# construction and matches Sidekiq's lifecycle.
|
|
13
14
|
#
|
|
14
15
|
# Spec: docs/target/sidekiq-free.md §10.1.
|
|
15
16
|
class Chain
|
|
@@ -83,23 +84,28 @@ module Wurk
|
|
|
83
84
|
# Walks the chain inside-out. Each middleware receives a block that
|
|
84
85
|
# advances to the next; the innermost block is the caller's `&block`,
|
|
85
86
|
# whose return value is propagated back out. Empty chain: `yield`.
|
|
87
|
+
#
|
|
88
|
+
# Hot path — one job pays this per invocation, so instantiation is folded
|
|
89
|
+
# into an index walk over `@entries`: no instance array from `retrieve`,
|
|
90
|
+
# no traversal lambda, and no `shift` (which memmoves the array per entry).
|
|
91
|
+
# Cost is one allocation per entry (the middleware itself), zero to walk.
|
|
86
92
|
def invoke(*args, &block)
|
|
87
93
|
raise ArgumentError, 'middleware chain requires a block' unless block
|
|
88
94
|
return yield if @entries.empty?
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
traverse = lambda do
|
|
92
|
-
if chain.empty?
|
|
93
|
-
block.call
|
|
94
|
-
else
|
|
95
|
-
chain.shift.call(*args, &traverse)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
traverse.call
|
|
96
|
+
traverse(@entries, 0, args, &block)
|
|
99
97
|
end
|
|
100
98
|
|
|
101
99
|
private
|
|
102
100
|
|
|
101
|
+
def traverse(entries, index, args, &block)
|
|
102
|
+
return yield if index == entries.size
|
|
103
|
+
|
|
104
|
+
entries[index].make_new(@config).call(*args) do
|
|
105
|
+
traverse(entries, index + 1, args, &block)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
103
109
|
# Custom dup semantics: deep-copy the entries array so a child chain's
|
|
104
110
|
# mutations don't bleed into the parent (or sibling capsules).
|
|
105
111
|
def initialize_copy(orig)
|
|
@@ -108,18 +114,29 @@ module Wurk
|
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
# Holds the klass + ctor args for a registered middleware. Defers
|
|
111
|
-
# instantiation until `retrieve` runs so each job gets a fresh
|
|
117
|
+
# instantiation until `retrieve`/`invoke` runs so each job gets a fresh
|
|
118
|
+
# object.
|
|
119
|
+
#
|
|
120
|
+
# Whether the class declares `config=` is resolved once, at registration,
|
|
121
|
+
# so the common case (the setter comes from `include
|
|
122
|
+
# Wurk::Middleware::ServerMiddleware`) skips a `respond_to?` on every job
|
|
123
|
+
# × every entry. It is a fast path, not the rule: the contract is "set
|
|
124
|
+
# `config` if the instance is respondable" (spec §10.1), which also covers
|
|
125
|
+
# a setter defined on the singleton in `initialize`, one reached through
|
|
126
|
+
# `method_missing`, or one added to the class after it was registered — so
|
|
127
|
+
# a class that does not declare it still gets asked.
|
|
112
128
|
class Entry
|
|
113
129
|
attr_reader :klass
|
|
114
130
|
|
|
115
131
|
def initialize(klass, *args)
|
|
116
132
|
@klass = klass
|
|
117
133
|
@args = args
|
|
134
|
+
@takes_config = klass.public_method_defined?(:config=)
|
|
118
135
|
end
|
|
119
136
|
|
|
120
137
|
def make_new(config = nil)
|
|
121
138
|
instance = @klass.new(*@args)
|
|
122
|
-
instance.config = config if config && instance.respond_to?(:config=)
|
|
139
|
+
instance.config = config if config && (@takes_config || instance.respond_to?(:config=))
|
|
123
140
|
instance
|
|
124
141
|
end
|
|
125
142
|
end
|
|
@@ -129,11 +129,32 @@ module Wurk
|
|
|
129
129
|
# Register a callback fired when a poison pill is detected. Callbacks
|
|
130
130
|
# receive a single Hash `{jid:, klass:, count:, queue:}` — matches
|
|
131
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.
|
|
132
139
|
def on_poison(&block)
|
|
133
140
|
raise ArgumentError, 'block required' unless block
|
|
134
141
|
|
|
135
|
-
callbacks << block
|
|
136
|
-
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
|
|
137
158
|
end
|
|
138
159
|
|
|
139
160
|
def callbacks
|
data/lib/wurk/pool_checkout.rb
CHANGED
|
@@ -25,5 +25,15 @@ module Wurk
|
|
|
25
25
|
|
|
26
26
|
pool.with(idempotent: true, &)
|
|
27
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
|
|
28
38
|
end
|
|
29
39
|
end
|
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
|
|
@@ -166,15 +235,15 @@ module Wurk
|
|
|
166
235
|
|
|
167
236
|
# The fetcher never parses, so hand it the jid we just read: the ACK
|
|
168
237
|
# retires this job's poison-pill recovery counter inside the round trip
|
|
169
|
-
# it
|
|
170
|
-
#
|
|
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.
|
|
171
240
|
uow.jid = job_hash['jid'] if uow.respond_to?(:jid=)
|
|
172
241
|
|
|
173
242
|
ack = false
|
|
174
243
|
begin
|
|
175
|
-
Thread.handle_interrupt(
|
|
244
|
+
Thread.handle_interrupt(IGNORE_SHUTDOWN_INTERRUPTS) do
|
|
176
245
|
dispatch(job_hash, queue, jobstr) do |instance|
|
|
177
|
-
Thread.handle_interrupt(
|
|
246
|
+
Thread.handle_interrupt(ALLOW_SHUTDOWN_INTERRUPTS) do
|
|
178
247
|
execute_job(instance, job_hash, queue)
|
|
179
248
|
end
|
|
180
249
|
end
|
|
@@ -247,15 +316,14 @@ module Wurk
|
|
|
247
316
|
# Heartbeat can mirror it into Redis (`<identity>:work`), and increments
|
|
248
317
|
# PROCESSED/FAILURE counters around the inner block.
|
|
249
318
|
def stats(jobstr, queue)
|
|
250
|
-
|
|
251
|
-
|
|
319
|
+
run_at = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second)
|
|
320
|
+
WORK_STATE.track(tid, queue: queue, payload: jobstr, run_at: run_at) do
|
|
252
321
|
yield
|
|
253
322
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
254
323
|
FAILURE.incr
|
|
255
324
|
raise
|
|
256
325
|
ensure
|
|
257
326
|
PROCESSED.incr
|
|
258
|
-
WORK_STATE.delete(tid)
|
|
259
327
|
end
|
|
260
328
|
end
|
|
261
329
|
end
|
data/lib/wurk/profiler.rb
CHANGED
|
@@ -34,11 +34,15 @@ module Wurk
|
|
|
34
34
|
# from the interrupt/expiry middleware) must propagate untouched so the
|
|
35
35
|
# retry/skip flow works. Only the storage step is made failure-safe
|
|
36
36
|
# (see #safe_store).
|
|
37
|
-
|
|
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)
|
|
38
42
|
label = job_hash['profile']
|
|
39
43
|
return yield unless label && defined?(::Vernier)
|
|
40
44
|
|
|
41
|
-
capture(job_hash, label
|
|
45
|
+
capture(job_hash, label) { yield } # rubocop:disable Style/ExplicitBlockArgument
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# Persists a profile. Extracted from capture so it is unit-testable
|
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
|
|
@@ -58,12 +64,14 @@ module Wurk
|
|
|
58
64
|
# 0 when the name was already present. In-flight jobs are untouched.
|
|
59
65
|
def pause! # rubocop:disable Naming/PredicateMethod
|
|
60
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
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
|
|
|
@@ -37,7 +37,7 @@ module Wurk
|
|
|
37
37
|
|
|
38
38
|
# The Redis commands Sidekiq itself uses — defined eagerly so the
|
|
39
39
|
# common ones skip method_missing. Same list as upstream.
|
|
40
|
-
USED_COMMANDS = %w[bitfield bitfield_ro del exists expire flushdb
|
|
40
|
+
USED_COMMANDS = %w[bitfield bitfield_ro blmove del exists expire flushdb
|
|
41
41
|
get hdel hget hgetall hincrby hlen hmget hset hsetnx incr incrby
|
|
42
42
|
lindex llen lmove lpop lpush lrange lrem mget mset ping pttl
|
|
43
43
|
publish rpop rpush sadd scard script set sismember smembers
|
data/lib/wurk/redis_pool.rb
CHANGED
|
@@ -190,9 +190,9 @@ module Wurk
|
|
|
190
190
|
# redials a closed socket lazily), so a busy fetcher can't leak checkouts.
|
|
191
191
|
def run(conn, idempotent)
|
|
192
192
|
attempts = 0
|
|
193
|
+
odometer = conn.round_trips
|
|
193
194
|
begin
|
|
194
195
|
attempts += 1
|
|
195
|
-
odometer = conn.round_trips
|
|
196
196
|
yield conn
|
|
197
197
|
rescue RedisClient::Error => e
|
|
198
198
|
plan = retry_plan(e, attempts, idempotent, conn.round_trips != odometer)
|
|
@@ -204,6 +204,7 @@ module Wurk
|
|
|
204
204
|
|
|
205
205
|
safe_close(conn)
|
|
206
206
|
sleep(backoff_delay(attempts)) if plan == :backoff
|
|
207
|
+
odometer = conn.round_trips
|
|
207
208
|
retry
|
|
208
209
|
end
|
|
209
210
|
end
|