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
|
@@ -16,6 +16,12 @@ module Wurk
|
|
|
16
16
|
# between fetch and ack leaves the job in the private list, where the
|
|
17
17
|
# next boot of this process reclaims it via bulk_requeue.
|
|
18
18
|
#
|
|
19
|
+
# The ACK does not take a round trip of its own: it is held here and
|
|
20
|
+
# pipelined in front of the next fetch's LMOVE, so a worker draining a busy
|
|
21
|
+
# queue costs one round trip per job in total. See #flush_pending_acks for
|
|
22
|
+
# the paths that must send a held ACK before they stop fetching, and
|
|
23
|
+
# docs/idea/parity-divergences.md for the window that widens.
|
|
24
|
+
#
|
|
19
25
|
# Priority handling: iterate queues_cmd in order with non-blocking
|
|
20
26
|
# LMOVE, then fall back to a blocking BLMOVE on the first queue so an
|
|
21
27
|
# empty poll doesn't spin Redis. BLMOVE has no multi-key form, so
|
|
@@ -31,6 +37,15 @@ module Wurk
|
|
|
31
37
|
# Default BLMOVE block timeout; overridable via config.fetch_poll_interval.
|
|
32
38
|
TIMEOUT = 2
|
|
33
39
|
|
|
40
|
+
# How long a fetcher may answer from its own copy of the `paused` SET
|
|
41
|
+
# before re-reading it. Deliberately equal to the default poll interval:
|
|
42
|
+
# a worker parked in BLMOVE already cannot observe a pause until that
|
|
43
|
+
# block returns, so caching the busy path for the same window leaves the
|
|
44
|
+
# fleet's worst-case pause latency where it was. A constant, never a
|
|
45
|
+
# config knob — see
|
|
46
|
+
# docs/plans/2026/08/06/101-faster-than-sidekiq/00-semantics-signoff.md.
|
|
47
|
+
PAUSED_TTL = 2
|
|
48
|
+
|
|
34
49
|
# Backoff for the quieted short-circuit. Manager#quiet terminates the
|
|
35
50
|
# shared fetcher before it terminates the processors, and Processor#run
|
|
36
51
|
# loops on its *own* flag — so in that window every processor would spin
|
|
@@ -38,36 +53,48 @@ module Wurk
|
|
|
38
53
|
# immediately after #quiet, so this pause adds no drain latency.
|
|
39
54
|
QUIET_PAUSE = 0.05
|
|
40
55
|
|
|
41
|
-
# Carries the public queue key, the raw (still-JSON) job payload,
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# each job's JSON
|
|
56
|
+
# Carries the public queue key, the raw (still-JSON) job payload, the
|
|
57
|
+
# capsule we use to reach Redis, and the fetcher that holds this unit's
|
|
58
|
+
# ACK until it can ride a pipeline. ACK removes from the private list;
|
|
59
|
+
# requeue pushes back to the public queue head so the job is next pulled.
|
|
60
|
+
# LREM count=1 is idempotent for our payloads since each job's JSON
|
|
61
|
+
# contains a unique `jid`.
|
|
62
|
+
#
|
|
63
|
+
# `queue_name` (the queue without its `queue:` prefix) and
|
|
64
|
+
# `private_queue` come off the fetcher's per-queue cache at build time:
|
|
65
|
+
# both are pure functions of the queue this unit came from, so deriving
|
|
66
|
+
# them here would be a per-job cost for a per-queue fact.
|
|
46
67
|
#
|
|
47
68
|
# `jid` is filled in by the Processor once it has parsed the payload —
|
|
48
69
|
# the fetcher never parses. It is only used to retire the job's
|
|
49
70
|
# poison-pill recovery counter, so an ACK without one is still a
|
|
50
71
|
# complete ACK.
|
|
51
|
-
UnitOfWork = Struct.new(:queue, :job, :config, :jid,
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
72
|
+
UnitOfWork = Struct.new(:queue, :queue_name, :private_queue, :job, :config, :jid, :fetcher,
|
|
73
|
+
keyword_init: true) do
|
|
74
|
+
# Deferred, never skipped: the LREM goes back to the fetcher, which
|
|
75
|
+
# pipelines it in front of the next fetch's LMOVE instead of spending a
|
|
76
|
+
# round trip of its own. Ordering against the job is unchanged — the
|
|
77
|
+
# LREM still happens only after success or retry handling (Pro §3.2) —
|
|
78
|
+
# so all that moves is the wall clock. Every path that stops fetching
|
|
79
|
+
# flushes first; see #flush_pending_acks.
|
|
56
80
|
def acknowledge
|
|
57
|
-
|
|
58
|
-
job_jid = jid.to_s
|
|
59
|
-
return config.redis { |conn| conn.call('LREM', private_list, 1, job) } if job_jid.empty?
|
|
60
|
-
|
|
61
|
-
config.redis do |conn|
|
|
62
|
-
conn.pipelined do |pipe|
|
|
63
|
-
pipe.call('LREM', private_list, 1, job)
|
|
64
|
-
Middleware::PoisonPill.clear_in(pipe, job_jid)
|
|
65
|
-
end
|
|
66
|
-
end
|
|
81
|
+
fetcher.defer_ack(self)
|
|
67
82
|
end
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
# Queue this unit's ACK into an already-open pipeline. The counter DEL
|
|
85
|
+
# rides the same round trip rather than taking one of its own: a
|
|
86
|
+
# per-job call would be a fetch+execute regression for the sake of a
|
|
87
|
+
# key that exists for roughly no jobs. See Middleware::PoisonPill.
|
|
88
|
+
#
|
|
89
|
+
# Sending it only for the jobs that own one is not available to us:
|
|
90
|
+
# whether a job was reclaimed lives in the counter, and a reclaimed
|
|
91
|
+
# payload is byte-identical to a first-attempt one (the job JSON is
|
|
92
|
+
# wire-frozen, so the reaper cannot flag it). Reading the counter to
|
|
93
|
+
# decide would spend the very round trip the DEL is riding for free.
|
|
94
|
+
def write_ack(pipe)
|
|
95
|
+
pipe.call('LREM', private_queue, 1, job)
|
|
96
|
+
job_jid = jid.to_s
|
|
97
|
+
Middleware::PoisonPill.clear_in(pipe, job_jid) unless job_jid.empty?
|
|
71
98
|
end
|
|
72
99
|
|
|
73
100
|
def requeue
|
|
@@ -75,10 +102,13 @@ module Wurk
|
|
|
75
102
|
end
|
|
76
103
|
end
|
|
77
104
|
|
|
78
|
-
# Class-level
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
105
|
+
# Class-level: the name is a pure function of the public queue and this
|
|
106
|
+
# process's identity, and both the fetcher and the Reaper need it (the
|
|
107
|
+
# fetcher's units carry the string #queue_keys built for them, so nothing
|
|
108
|
+
# on the hot path calls this per job). Index defaults to 0 — we run one
|
|
109
|
+
# fetcher per capsule today.
|
|
110
|
+
# Multi-processor topology (one private list per processor slot) is a
|
|
111
|
+
# future Manager concern.
|
|
82
112
|
#
|
|
83
113
|
# The nonce marks the incarnation. host+pid alone is ambiguous once PID
|
|
84
114
|
# namespaces are in play: a restarted container reuses both, so the
|
|
@@ -91,10 +121,82 @@ module Wurk
|
|
|
91
121
|
"#{public_queue}|#{host}|#{::Process.pid}|#{Component::PROCESS_NONCE}|#{index}"
|
|
92
122
|
end
|
|
93
123
|
|
|
124
|
+
# Guards the generation bump only. Fetchers read the counter without it —
|
|
125
|
+
# a torn read is impossible for an Integer reference, and a fetcher that
|
|
126
|
+
# misses a bump by microseconds picks it up on its next pass.
|
|
127
|
+
PAUSED_GENERATION_LOCK = Mutex.new
|
|
128
|
+
|
|
129
|
+
@paused_generation = 0
|
|
130
|
+
|
|
131
|
+
class << self
|
|
132
|
+
# Every fetcher in this process caches the paused SET against this
|
|
133
|
+
# counter, so bumping it expires all of them at once.
|
|
134
|
+
attr_reader :paused_generation
|
|
135
|
+
|
|
136
|
+
# Queue#pause!/#unpause! call this. Without it a host app that pauses a
|
|
137
|
+
# queue from inside a job would keep watching its own workers drain that
|
|
138
|
+
# queue for up to PAUSED_TTL — the one staleness the sign-off refuses.
|
|
139
|
+
def invalidate_paused_cache!
|
|
140
|
+
PAUSED_GENERATION_LOCK.synchronize { @paused_generation += 1 }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
94
144
|
def initialize(capsule)
|
|
95
145
|
super()
|
|
96
146
|
@config = capsule
|
|
97
147
|
@done = false
|
|
148
|
+
@paused = nil
|
|
149
|
+
@paused_generation = nil
|
|
150
|
+
@paused_expires_at = 0.0
|
|
151
|
+
@pending_acks = {}
|
|
152
|
+
@pending_lock = ::Mutex.new
|
|
153
|
+
@queue_keys = {}
|
|
154
|
+
@queue_keys_pid = ::Process.pid
|
|
155
|
+
@prefixed_queues = nil
|
|
156
|
+
@prefixed_source = nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Take custody of a finished job's LREM instead of sending it now. One
|
|
160
|
+
# slot per processor thread: a capsule shares a single fetcher across its
|
|
161
|
+
# processors, and each thread's fetch → execute → ACK cycle is strictly
|
|
162
|
+
# sequential, so a thread only ever writes its own slot. The lock is for
|
|
163
|
+
# the flush paths, which drain every slot from a different thread.
|
|
164
|
+
#
|
|
165
|
+
# The slot holds a list rather than a single unit because a failed flush
|
|
166
|
+
# can hand an older ACK back to a thread that has already deferred a newer
|
|
167
|
+
# one (see #restore_pending_acks). Everywhere else it holds exactly one,
|
|
168
|
+
# and the array is reused empty rather than reallocated per job.
|
|
169
|
+
def defer_ack(uow)
|
|
170
|
+
@pending_lock.synchronize { (@pending_acks[::Thread.current] ||= []) << uow }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Send every held ACK now, in one pipeline of its own.
|
|
174
|
+
#
|
|
175
|
+
# Called from every path that stops fetching — nothing else would send
|
|
176
|
+
# them — and from #bulk_requeue, where it is a correctness requirement
|
|
177
|
+
# rather than an optimization: a finished job whose LREM is still pending
|
|
178
|
+
# is not in Manager#hard_shutdown's in-flight list, so the requeue Lua's
|
|
179
|
+
# LREM guard would still find its payload, RPUSH it onto the public
|
|
180
|
+
# queue, and run it a second time on every graceful shutdown.
|
|
181
|
+
def flush_pending_acks
|
|
182
|
+
pending = claim_pending_acks
|
|
183
|
+
# A thread whose ACK already rode a fetch leaves its queue behind empty;
|
|
184
|
+
# dropping the hash those queues lived in is also how the entry of a
|
|
185
|
+
# processor thread that has since died is reclaimed.
|
|
186
|
+
return if pending.each_value.all?(&:empty?)
|
|
187
|
+
|
|
188
|
+
begin
|
|
189
|
+
# Apply-safe for the same reason the piggybacked copy is: a replayed
|
|
190
|
+
# LREM finds the payload already gone and removes nothing, and the
|
|
191
|
+
# counter DEL is idempotent by definition. Claiming it buys the drain
|
|
192
|
+
# path the full connection-blip backoff.
|
|
193
|
+
config.redis(idempotent: true) do |conn|
|
|
194
|
+
conn.pipelined { |pipe| pending.each_value { |uows| uows.each { |uow| uow.write_ack(pipe) } } }
|
|
195
|
+
end
|
|
196
|
+
rescue StandardError
|
|
197
|
+
restore_pending_acks(pending)
|
|
198
|
+
raise
|
|
199
|
+
end
|
|
98
200
|
end
|
|
99
201
|
|
|
100
202
|
# Every pass that yields no job has to cost wall-clock time: Processor#run
|
|
@@ -104,25 +206,22 @@ module Wurk
|
|
|
104
206
|
# the two short-circuits below have to pay it themselves.
|
|
105
207
|
def retrieve_work
|
|
106
208
|
if @done
|
|
209
|
+
flush_pending_acks
|
|
107
210
|
sleep QUIET_PAUSE
|
|
108
211
|
return nil
|
|
109
212
|
end
|
|
110
213
|
|
|
111
214
|
queues = queues_cmd
|
|
112
215
|
# Nothing fetchable — every queue paused, or none configured. Back off a
|
|
113
|
-
# full poll interval rather than re-running queues_cmd
|
|
114
|
-
#
|
|
115
|
-
# BasicFetch guard, upstream #4825.
|
|
216
|
+
# full poll interval rather than re-running queues_cmd as fast as the CPU
|
|
217
|
+
# allows. Mirrors Sidekiq's BasicFetch guard, upstream #4825.
|
|
116
218
|
if queues.empty?
|
|
219
|
+
flush_pending_acks
|
|
117
220
|
sleep poll_interval
|
|
118
221
|
return nil
|
|
119
222
|
end
|
|
120
223
|
|
|
121
|
-
queues
|
|
122
|
-
uow = lmove(public_q)
|
|
123
|
-
return uow if uow
|
|
124
|
-
end
|
|
125
|
-
blmove(queues.first)
|
|
224
|
+
walk(queues)
|
|
126
225
|
end
|
|
127
226
|
|
|
128
227
|
# Called on shutdown for jobs the Processor couldn't finish in time.
|
|
@@ -137,22 +236,30 @@ module Wurk
|
|
|
137
236
|
# in-flight in the private list until the next boot; we prefer the
|
|
138
237
|
# immediate move so a rolling deploy recovers work without a restart.
|
|
139
238
|
def bulk_requeue(in_progress)
|
|
239
|
+
# First and unconditional — see #flush_pending_acks. Deliberately not
|
|
240
|
+
# rescued: if the ACKs could not be sent we would be requeueing jobs
|
|
241
|
+
# whose completion we failed to record. Leaving them in the private
|
|
242
|
+
# list for the next boot's reaper is the safer of the two.
|
|
243
|
+
flush_pending_acks
|
|
140
244
|
return if in_progress.nil? || in_progress.empty?
|
|
141
245
|
|
|
142
246
|
config.redis { |conn| requeue_pipelined(conn, in_progress) }
|
|
143
247
|
end
|
|
144
248
|
|
|
145
249
|
# Prefixed queue keys (`queue:<name>`) in fetch order. Strict mode
|
|
146
|
-
# preserves declaration order
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
250
|
+
# preserves declaration order and, with nothing paused, hands back the
|
|
251
|
+
# prebuilt array as-is — the steady-state fetch allocates nothing here,
|
|
252
|
+
# matching Sidekiq's own strict path (fetch.rb:79-87). Random/weighted
|
|
253
|
+
# shuffle each call — `@queues` is pre-expanded by weight in
|
|
254
|
+
# Capsule#queues=, so uniform shuffle yields weighted fairness; .uniq
|
|
255
|
+
# trims duplicates. Paused queues are filtered after shuffle so the
|
|
256
|
+
# membership test runs on the smallest possible set.
|
|
151
257
|
def queues_cmd
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
258
|
+
paused = paused_keys
|
|
259
|
+
keys = config.mode == :strict ? prefixed_queues : prefixed_queues.shuffle.uniq
|
|
260
|
+
return keys if paused.empty?
|
|
261
|
+
|
|
262
|
+
keys.reject { |key| paused.include?(key) }
|
|
156
263
|
end
|
|
157
264
|
|
|
158
265
|
# Quiet hook (Manager#quiet). Flips the drain flag so retrieve_work
|
|
@@ -160,12 +267,75 @@ module Wurk
|
|
|
160
267
|
# one sitting in the between-jobs window (Processor#run only re-checks its
|
|
161
268
|
# own @done between iterations). Quiet is one-way — matches Sidekiq TSTP
|
|
162
269
|
# (spec §21.3), there is no un-terminate.
|
|
270
|
+
#
|
|
271
|
+
# Which is exactly why it flushes: after this, retrieve_work short-circuits
|
|
272
|
+
# for good, so an ACK held here would otherwise sit until shutdown.
|
|
163
273
|
def terminate
|
|
164
274
|
@done = true
|
|
275
|
+
flush_pending_acks
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
# Runs on the Manager's thread mid-shutdown, where a raise would skip
|
|
278
|
+
# the rest of the quiet path. The ACKs are back in their slots, so the
|
|
279
|
+
# next flush point (Processor's ensure) retries them.
|
|
280
|
+
handle_exception(e, { context: 'Error flushing pending acks' })
|
|
165
281
|
end
|
|
166
282
|
|
|
167
283
|
private
|
|
168
284
|
|
|
285
|
+
# Capsule doesn't define handle_exception (it's a Configuration method);
|
|
286
|
+
# override Component's delegation so error handlers fire.
|
|
287
|
+
def handle_exception(ex, ctx = {})
|
|
288
|
+
config.config.handle_exception(ex, ctx)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Non-blocking pass over the fetchable queues. The pending ACK rides the
|
|
292
|
+
# first LMOVE and only the first: the walk is one fetch, so a second copy
|
|
293
|
+
# of the same LREM would be a wasted command on every queue we find
|
|
294
|
+
# empty. By the time we fall through to BLMOVE this thread is holding
|
|
295
|
+
# nothing — which is the point, since a blocking call can't join a
|
|
296
|
+
# pipeline and would strand the ACK for a whole poll interval.
|
|
297
|
+
def walk(queues)
|
|
298
|
+
ack = take_pending_ack
|
|
299
|
+
queues.each do |public_q|
|
|
300
|
+
uow = lmove(public_q, ack)
|
|
301
|
+
ack = nil
|
|
302
|
+
return uow if uow
|
|
303
|
+
end
|
|
304
|
+
blmove(queues.first)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Exactly one, even when a failed flush left this thread holding two: the
|
|
308
|
+
# walk is one fetch and one pipeline, and the rest go out on the next.
|
|
309
|
+
def take_pending_ack
|
|
310
|
+
@pending_lock.synchronize { @pending_acks[::Thread.current]&.shift }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def claim_pending_acks
|
|
314
|
+
@pending_lock.synchronize do
|
|
315
|
+
held = @pending_acks
|
|
316
|
+
@pending_acks = {}
|
|
317
|
+
held
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Put a failed flush's ACKs back rather than drop them: an LREM that is
|
|
322
|
+
# never sent leaves a finished job in the private list for the next boot's
|
|
323
|
+
# reaper to reclaim and run again.
|
|
324
|
+
#
|
|
325
|
+
# Prepended per thread rather than merged by thread: the flush runs while
|
|
326
|
+
# the processors keep working, so a thread we claimed an ACK from may have
|
|
327
|
+
# finished another job and deferred a second one meanwhile. Keeping only
|
|
328
|
+
# one of the two re-runs whichever job lost.
|
|
329
|
+
def restore_pending_acks(pending)
|
|
330
|
+
@pending_lock.synchronize do
|
|
331
|
+
pending.each do |thread, uows|
|
|
332
|
+
next if uows.empty?
|
|
333
|
+
|
|
334
|
+
(@pending_acks[thread] ||= []).unshift(*uows)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
169
339
|
# One pipelined RELIABLE_REQUEUE EVALSHA per UoW. Mirrors
|
|
170
340
|
# Client#push_batched_pipelined: a pipelined EVALSHA surfaces NOSCRIPT
|
|
171
341
|
# only at finalize (never to eval_cached's inline rescue). Every command
|
|
@@ -177,7 +347,7 @@ module Wurk
|
|
|
177
347
|
in_progress.each do |uow|
|
|
178
348
|
Wurk::Lua::Loader.public_send(
|
|
179
349
|
eval_method, pipe, :reliable_requeue,
|
|
180
|
-
keys: [
|
|
350
|
+
keys: [queue_keys(uow.queue).first, uow.queue],
|
|
181
351
|
argv: [uow.job]
|
|
182
352
|
)
|
|
183
353
|
end
|
|
@@ -189,12 +359,79 @@ module Wurk
|
|
|
189
359
|
requeue_pipelined(conn, in_progress, eval_method: :eval_with_source)
|
|
190
360
|
end
|
|
191
361
|
|
|
192
|
-
# SMEMBERS of the `paused` SET
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
# lookup against the (often
|
|
196
|
-
|
|
197
|
-
|
|
362
|
+
# SMEMBERS of the `paused` SET, at most once per PAUSED_TTL per fetcher
|
|
363
|
+
# instead of once per fetch pass — on a busy queue that was a full round
|
|
364
|
+
# trip per job spent re-confirming a set that is empty for almost every
|
|
365
|
+
# install. Returns a Set for O(1) lookup against the (often
|
|
366
|
+
# weighted-expanded) queue list.
|
|
367
|
+
#
|
|
368
|
+
# Members are unprefixed on the wire (Sidekiq Pro's `paused` SET, which we
|
|
369
|
+
# never touch); the cache stores them prefixed because the only reader
|
|
370
|
+
# tests them against the `queue:<name>` keys it is about to LMOVE, and
|
|
371
|
+
# prefixing here is once per TTL instead of once per queue per pass.
|
|
372
|
+
#
|
|
373
|
+
# Monotonic clock, so an NTP step can't pin the cache open. Per instance,
|
|
374
|
+
# so it dies with the fetcher and can never cross a fork.
|
|
375
|
+
#
|
|
376
|
+
# Processor threads share one fetcher and race here. The loser pays a
|
|
377
|
+
# second SMEMBERS and overwrites with an equally fresh read; @paused is
|
|
378
|
+
# published before the freshness stamps so a reader that trusts the stamps
|
|
379
|
+
# can never see the value they don't belong to.
|
|
380
|
+
def paused_keys
|
|
381
|
+
generation = self.class.paused_generation
|
|
382
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
383
|
+
return @paused if @paused_generation == generation && now < @paused_expires_at
|
|
384
|
+
|
|
385
|
+
members = config.redis(idempotent: true) { |conn| conn.call('SMEMBERS', Keys::PAUSED_SET) }
|
|
386
|
+
@paused = members.to_set { |q| "#{Keys::QUEUE_PREFIX}#{q}" }
|
|
387
|
+
@paused_generation = generation
|
|
388
|
+
@paused_expires_at = now + PAUSED_TTL
|
|
389
|
+
@paused
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# `queue:<name>` for every queue this capsule serves. Rebuilt only when
|
|
393
|
+
# Capsule#queues= swaps the list in — it always allocates a fresh array,
|
|
394
|
+
# so identity is the whole check. Published before the source it came
|
|
395
|
+
# from, so a reader that trusts the source can never see a list built
|
|
396
|
+
# from a different one.
|
|
397
|
+
def prefixed_queues
|
|
398
|
+
source = config.queues
|
|
399
|
+
return @prefixed_queues if @prefixed_source.equal?(source)
|
|
400
|
+
|
|
401
|
+
@prefixed_queues = source.map { |q| "#{Keys::QUEUE_PREFIX}#{q}".freeze }.freeze
|
|
402
|
+
@prefixed_source = source
|
|
403
|
+
@prefixed_queues
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# The two strings a fetch derives from a public queue key: this process's
|
|
407
|
+
# private list for it (a `gethostname` syscall plus a five-part
|
|
408
|
+
# interpolation) and the unprefixed name the Processor tags the job with.
|
|
409
|
+
# Both are pure functions of the queue and this process's identity, so
|
|
410
|
+
# they cost one build per queue rather than two per job.
|
|
411
|
+
#
|
|
412
|
+
# Rebuilt when the pid moves: a fetcher materialized before a fork would
|
|
413
|
+
# otherwise keep claiming into a private list stamped with the parent's
|
|
414
|
+
# pid, which the Reaper reads as owned by a live process — so nothing this
|
|
415
|
+
# child leaves behind would ever be reclaimed.
|
|
416
|
+
#
|
|
417
|
+
# Copy-on-write rather than mutated in place: processor threads share one
|
|
418
|
+
# fetcher and read this without a lock. A racing writer's entry can be
|
|
419
|
+
# lost, and the next fetch rebuilds it.
|
|
420
|
+
def queue_keys(public_q)
|
|
421
|
+
pid = ::Process.pid
|
|
422
|
+
if @queue_keys_pid != pid
|
|
423
|
+
@queue_keys = {}
|
|
424
|
+
@queue_keys_pid = pid
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
cache = @queue_keys
|
|
428
|
+
cached = cache[public_q]
|
|
429
|
+
return cached if cached
|
|
430
|
+
|
|
431
|
+
built = [self.class.private_queue_name(public_q).freeze,
|
|
432
|
+
public_q.delete_prefix(Keys::QUEUE_PREFIX).freeze].freeze
|
|
433
|
+
@queue_keys = cache.merge(public_q => built)
|
|
434
|
+
built
|
|
198
435
|
end
|
|
199
436
|
|
|
200
437
|
# Both LMOVE forms claim apply-safety, so fetch keeps the full
|
|
@@ -204,24 +441,49 @@ module Wurk
|
|
|
204
441
|
# and ack leaves behind, which the next boot's Reaper already reclaims. The
|
|
205
442
|
# replay then pulls a different job; nothing duplicates and nothing is
|
|
206
443
|
# lost, at worst one job waits out this process's lifetime.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
444
|
+
# A replayed ACK is a no-op (LREM removes nothing the second time, DEL is
|
|
445
|
+
# already done), so folding it in costs the retry nothing.
|
|
446
|
+
def lmove(public_q, ack = nil)
|
|
447
|
+
priv, name = queue_keys(public_q)
|
|
448
|
+
job = config.redis(idempotent: true) do |conn|
|
|
449
|
+
if ack
|
|
450
|
+
conn.pipelined do |pipe|
|
|
451
|
+
ack.write_ack(pipe)
|
|
452
|
+
pipe.call('LMOVE', public_q, priv, 'RIGHT', 'LEFT')
|
|
453
|
+
end.last
|
|
454
|
+
else
|
|
455
|
+
conn.call('LMOVE', public_q, priv, 'RIGHT', 'LEFT')
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
job ? unit_of_work(public_q, priv, name, job) : nil
|
|
459
|
+
rescue StandardError
|
|
460
|
+
# The ACK left its slot but never reached Redis. Hand it back so a
|
|
461
|
+
# later flush still retires the job — otherwise a finished job sits in
|
|
462
|
+
# the private list until the next boot's reaper runs it again.
|
|
463
|
+
defer_ack(ack) if ack
|
|
464
|
+
raise
|
|
211
465
|
end
|
|
212
466
|
|
|
213
467
|
def blmove(public_q)
|
|
214
|
-
priv =
|
|
468
|
+
priv, name = queue_keys(public_q)
|
|
215
469
|
timeout = poll_interval
|
|
216
470
|
# Dedicated fetch pool, not the main one: a parked BLMOVE holds its slot
|
|
217
471
|
# for the whole block window, so routing it here keeps idle fetchers from
|
|
218
|
-
# starving the main pool's background loops (#101).
|
|
219
|
-
#
|
|
220
|
-
#
|
|
472
|
+
# starving the main pool's background loops (#101). redis-client's
|
|
473
|
+
# connection_timeout helper (connection_mixin.rb:87-93) adds config.read_timeout
|
|
474
|
+
# to the blocking call timeout, ensuring the socket read timeout won't fire
|
|
475
|
+
# while BLMOVE is legitimately blocked.
|
|
221
476
|
job = config.fetch_redis(idempotent: true) do |conn|
|
|
222
|
-
conn.blocking_call(timeout
|
|
477
|
+
conn.blocking_call(timeout, 'BLMOVE', public_q, priv, 'RIGHT', 'LEFT', timeout)
|
|
223
478
|
end
|
|
224
|
-
job ?
|
|
479
|
+
job ? unit_of_work(public_q, priv, name, job) : nil
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Both fetch paths hand the unit every string its Processor and its ACK
|
|
483
|
+
# will need, so neither re-derives one per job.
|
|
484
|
+
def unit_of_work(public_q, priv, name, job)
|
|
485
|
+
UnitOfWork.new(queue: public_q, queue_name: name, private_queue: priv,
|
|
486
|
+
job: job, config: config, fetcher: self)
|
|
225
487
|
end
|
|
226
488
|
|
|
227
489
|
# BLMOVE block timeout for an empty poll. `config.fetch_poll_interval`
|
data/lib/wurk/fetcher.rb
CHANGED
|
@@ -12,5 +12,11 @@ module Wurk
|
|
|
12
12
|
# and stop pulling new work the instant a process is quieted. No-op in the
|
|
13
13
|
# abstract base; Reliable flips its drain flag.
|
|
14
14
|
def terminate; end
|
|
15
|
+
|
|
16
|
+
# Send any ACK the fetcher is holding back rather than sending immediately.
|
|
17
|
+
# Processor calls this as it stops, so a job that finished but whose ACK
|
|
18
|
+
# never caught a fetch to ride on can't be reclaimed as unfinished. No-op
|
|
19
|
+
# in the abstract base; only Reliable defers.
|
|
20
|
+
def flush_pending_acks; end
|
|
15
21
|
end
|
|
16
22
|
end
|
data/lib/wurk/heartbeat.rb
CHANGED
|
@@ -190,7 +190,7 @@ module Wurk
|
|
|
190
190
|
|
|
191
191
|
def cpu_model
|
|
192
192
|
if ::File.exist?('/proc/cpuinfo')
|
|
193
|
-
line = ::File.
|
|
193
|
+
line = ::File.open('/proc/cpuinfo') { |f| f.find { |l| l.start_with?('model name') } }
|
|
194
194
|
line&.split(':', 2)&.last&.strip
|
|
195
195
|
else
|
|
196
196
|
model = `sysctl -n machdep.cpu.brand_string 2>/dev/null`.strip
|
|
@@ -202,7 +202,7 @@ module Wurk
|
|
|
202
202
|
|
|
203
203
|
def memory_total_kb
|
|
204
204
|
if ::File.exist?('/proc/meminfo')
|
|
205
|
-
line = ::File.
|
|
205
|
+
line = ::File.open('/proc/meminfo') { |f| f.find { |l| l.start_with?('MemTotal') } }
|
|
206
206
|
line.to_s[/\d+/].to_i
|
|
207
207
|
else
|
|
208
208
|
`sysctl -n hw.memsize 2>/dev/null`.to_i / 1024
|
data/lib/wurk/job_logger.rb
CHANGED
|
@@ -41,13 +41,7 @@ module Wurk
|
|
|
41
41
|
# under a per-job log level. ActiveJob-wrapped jobs expose the real
|
|
42
42
|
# class via the "wrapped" key — log that, not the wrapper.
|
|
43
43
|
def prepare(job_hash, &block)
|
|
44
|
-
h =
|
|
45
|
-
jid: job_hash['jid'],
|
|
46
|
-
class: job_hash['wrapped'] || job_hash['class']
|
|
47
|
-
}
|
|
48
|
-
@config[:logged_job_attributes].each do |attr|
|
|
49
|
-
h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
|
|
50
|
-
end
|
|
44
|
+
h = context_hash(job_hash)
|
|
51
45
|
|
|
52
46
|
level = job_hash['log_level']
|
|
53
47
|
Wurk::Context.with(h) do
|
|
@@ -64,5 +58,20 @@ module Wurk
|
|
|
64
58
|
def elapsed(start)
|
|
65
59
|
(::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(3)
|
|
66
60
|
end
|
|
61
|
+
|
|
62
|
+
# The attribute list is a host setting (defaults to %w[bid tags]), so the
|
|
63
|
+
# walk cannot be short-circuited on the default keys: a host that adds
|
|
64
|
+
# `tenant_id` logs jobs that carry neither `bid` nor `tags`.
|
|
65
|
+
def context_hash(job_hash)
|
|
66
|
+
h = {
|
|
67
|
+
jid: job_hash['jid'],
|
|
68
|
+
class: job_hash['wrapped'] || job_hash['class']
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@config[:logged_job_attributes].each do |attr|
|
|
72
|
+
h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
|
|
73
|
+
end
|
|
74
|
+
h
|
|
75
|
+
end
|
|
67
76
|
end
|
|
68
77
|
end
|
data/lib/wurk/job_set.rb
CHANGED
|
@@ -65,8 +65,9 @@ module Wurk
|
|
|
65
65
|
#
|
|
66
66
|
# Spec: docs/target/sidekiq-free.md §19.5.
|
|
67
67
|
class JobSet < SortedSet
|
|
68
|
-
# ZADD
|
|
69
|
-
#
|
|
68
|
+
# Plain ZADD, no NX: re-scheduling an identical payload *moves* it to the
|
|
69
|
+
# new timestamp rather than keeping the first one. Mirrors
|
|
70
|
+
# Sidekiq::JobSet#schedule exactly — the last schedule call wins.
|
|
70
71
|
def schedule(timestamp, message)
|
|
71
72
|
Wurk.redis { |conn| conn.call('ZADD', @name, timestamp.to_f.to_s, Wurk.dump_json(message)) }
|
|
72
73
|
end
|
data/lib/wurk/job_util.rb
CHANGED
|
@@ -140,32 +140,52 @@ module Wurk
|
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
JSON_NATIVE = ->(_val) {}
|
|
144
|
+
|
|
145
|
+
# Dispatch table for the args walk, ported from Sidekiq (job_util.rb:80-111).
|
|
146
|
+
# Keyed by `val.class` under `compare_by_identity`, so every node costs one
|
|
147
|
+
# identity lookup + one call instead of the ladder of `Module#===` checks a
|
|
148
|
+
# `case/when` compiles to — the entry node is always the args Array, which
|
|
149
|
+
# sat behind six failing `===` probes.
|
|
150
|
+
#
|
|
151
|
+
# Identity lookup means a *subclass* of a JSON-native type misses the table
|
|
152
|
+
# and falls to the default lambda, i.e. is reported unsafe: an
|
|
153
|
+
# ActiveSupport::SafeBuffer arg raises where a bare String does not. That is
|
|
154
|
+
# Sidekiq's behavior and the drop-in contract, not an oversight. Hash keys
|
|
155
|
+
# are the one exception — they are checked with `is_a?`, so a String
|
|
156
|
+
# subclass key is accepted, again matching Sidekiq.
|
|
157
|
+
RECURSIVE_JSON_UNSAFE = { # rubocop:disable Style/MutableConstant -- frozen below, after the two setup calls
|
|
158
|
+
Integer => JSON_NATIVE,
|
|
159
|
+
Float => JSON_NATIVE,
|
|
160
|
+
TrueClass => JSON_NATIVE,
|
|
161
|
+
FalseClass => JSON_NATIVE,
|
|
162
|
+
NilClass => JSON_NATIVE,
|
|
163
|
+
String => JSON_NATIVE,
|
|
164
|
+
Array => lambda { |val|
|
|
165
|
+
val.each do |e|
|
|
166
|
+
bad = RECURSIVE_JSON_UNSAFE[e.class].call(e)
|
|
167
|
+
return bad unless bad.nil?
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
},
|
|
171
|
+
Hash => lambda { |val|
|
|
172
|
+
val.each do |k, v|
|
|
173
|
+
return k unless k.is_a?(String)
|
|
174
|
+
|
|
175
|
+
bad = RECURSIVE_JSON_UNSAFE[v.class].call(v)
|
|
176
|
+
return bad unless bad.nil?
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
RECURSIVE_JSON_UNSAFE.default = ->(val) { val }
|
|
182
|
+
RECURSIVE_JSON_UNSAFE.compare_by_identity
|
|
183
|
+
RECURSIVE_JSON_UNSAFE.freeze
|
|
184
|
+
private_constant :JSON_NATIVE, :RECURSIVE_JSON_UNSAFE
|
|
185
|
+
|
|
143
186
|
# Returns the first offending value, or nil when the tree is JSON-native.
|
|
144
187
|
def json_unsafe(obj)
|
|
145
|
-
|
|
146
|
-
when String, Integer, Float, TrueClass, FalseClass, NilClass then nil
|
|
147
|
-
when Array then json_unsafe_array(obj)
|
|
148
|
-
when Hash then json_unsafe_hash(obj)
|
|
149
|
-
else obj
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def json_unsafe_array(arr)
|
|
154
|
-
arr.each do |v|
|
|
155
|
-
bad = json_unsafe(v)
|
|
156
|
-
return bad unless bad.nil?
|
|
157
|
-
end
|
|
158
|
-
nil
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def json_unsafe_hash(hash)
|
|
162
|
-
hash.each do |k, v|
|
|
163
|
-
return k unless k.is_a?(String)
|
|
164
|
-
|
|
165
|
-
bad = json_unsafe(v)
|
|
166
|
-
return bad unless bad.nil?
|
|
167
|
-
end
|
|
168
|
-
nil
|
|
188
|
+
RECURSIVE_JSON_UNSAFE[obj.class].call(obj)
|
|
169
189
|
end
|
|
170
190
|
end
|
|
171
191
|
end
|