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
|
@@ -5,16 +5,23 @@ require_relative '../component'
|
|
|
5
5
|
require_relative '../keys'
|
|
6
6
|
require_relative '../lua'
|
|
7
7
|
require_relative '../fetcher'
|
|
8
|
+
require_relative '../middleware/poison_pill'
|
|
8
9
|
|
|
9
10
|
module Wurk
|
|
10
11
|
class Fetcher
|
|
11
12
|
# Default fetcher. Each public queue is paired with a per-process
|
|
12
|
-
# private list (`queue:<name>|<host>|<pid>|<idx>`); a job is moved
|
|
13
|
+
# private list (`queue:<name>|<host>|<pid>|<nonce>|<idx>`); a job is moved
|
|
13
14
|
# atomically from the public tail to the private head via LMOVE, and
|
|
14
15
|
# stays there until the Processor explicitly ACKs (LREM). SIGKILL
|
|
15
16
|
# between fetch and ack leaves the job in the private list, where the
|
|
16
17
|
# next boot of this process reclaims it via bulk_requeue.
|
|
17
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
|
+
#
|
|
18
25
|
# Priority handling: iterate queues_cmd in order with non-blocking
|
|
19
26
|
# LMOVE, then fall back to a blocking BLMOVE on the first queue so an
|
|
20
27
|
# empty poll doesn't spin Redis. BLMOVE has no multi-key form, so
|
|
@@ -30,20 +37,64 @@ module Wurk
|
|
|
30
37
|
# Default BLMOVE block timeout; overridable via config.fetch_poll_interval.
|
|
31
38
|
TIMEOUT = 2
|
|
32
39
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
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
|
+
|
|
49
|
+
# Backoff for the quieted short-circuit. Manager#quiet terminates the
|
|
50
|
+
# shared fetcher before it terminates the processors, and Processor#run
|
|
51
|
+
# loops on its *own* flag — so in that window every processor would spin
|
|
52
|
+
# on an instant nil. Kept below Manager::PAUSE_TIME, which #stop sleeps
|
|
53
|
+
# immediately after #quiet, so this pause adds no drain latency.
|
|
54
|
+
QUIET_PAUSE = 0.05
|
|
55
|
+
|
|
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.
|
|
67
|
+
#
|
|
68
|
+
# `jid` is filled in by the Processor once it has parsed the payload —
|
|
69
|
+
# the fetcher never parses. It is only used to retire the job's
|
|
70
|
+
# poison-pill recovery counter, so an ACK without one is still a
|
|
71
|
+
# complete ACK.
|
|
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.
|
|
39
80
|
def acknowledge
|
|
40
|
-
|
|
41
|
-
conn.call('LREM', Reliable.private_queue_name(queue), 1, job)
|
|
42
|
-
end
|
|
81
|
+
fetcher.defer_ack(self)
|
|
43
82
|
end
|
|
44
83
|
|
|
45
|
-
|
|
46
|
-
|
|
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?
|
|
47
98
|
end
|
|
48
99
|
|
|
49
100
|
def requeue
|
|
@@ -51,32 +102,126 @@ module Wurk
|
|
|
51
102
|
end
|
|
52
103
|
end
|
|
53
104
|
|
|
54
|
-
# Class-level
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
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.
|
|
112
|
+
#
|
|
113
|
+
# The nonce marks the incarnation. host+pid alone is ambiguous once PID
|
|
114
|
+
# namespaces are in play: a restarted container reuses both, so the
|
|
115
|
+
# reaper's `kill(0)` liveness check would read a dead owner's list as
|
|
116
|
+
# live (jobs stranded) or a live owner's as dead (job run twice). Keys
|
|
117
|
+
# written before the nonce existed stay reclaimable — Reaper#parse_owner
|
|
118
|
+
# accepts both shapes.
|
|
58
119
|
def self.private_queue_name(public_queue, index = 0)
|
|
59
120
|
host = ENV['DYNO'] || Socket.gethostname
|
|
60
|
-
"#{public_queue}|#{host}|#{::Process.pid}|#{index}"
|
|
121
|
+
"#{public_queue}|#{host}|#{::Process.pid}|#{Component::PROCESS_NONCE}|#{index}"
|
|
122
|
+
end
|
|
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
|
|
61
142
|
end
|
|
62
143
|
|
|
63
144
|
def initialize(capsule)
|
|
64
145
|
super()
|
|
65
146
|
@config = capsule
|
|
66
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
|
|
67
200
|
end
|
|
68
201
|
|
|
202
|
+
# Every pass that yields no job has to cost wall-clock time: Processor#run
|
|
203
|
+
# drives `process_one` in a bare `until @done` loop with no pause of its
|
|
204
|
+
# own, so any nil returned instantly turns N processor threads into a hot
|
|
205
|
+
# loop. The blocking BLMOVE pays that cost on the normal empty-queue path;
|
|
206
|
+
# the two short-circuits below have to pay it themselves.
|
|
69
207
|
def retrieve_work
|
|
70
|
-
|
|
208
|
+
if @done
|
|
209
|
+
flush_pending_acks
|
|
210
|
+
sleep QUIET_PAUSE
|
|
211
|
+
return nil
|
|
212
|
+
end
|
|
71
213
|
|
|
72
214
|
queues = queues_cmd
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
215
|
+
# Nothing fetchable — every queue paused, or none configured. Back off a
|
|
216
|
+
# full poll interval rather than re-running queues_cmd as fast as the CPU
|
|
217
|
+
# allows. Mirrors Sidekiq's BasicFetch guard, upstream #4825.
|
|
218
|
+
if queues.empty?
|
|
219
|
+
flush_pending_acks
|
|
220
|
+
sleep poll_interval
|
|
221
|
+
return nil
|
|
78
222
|
end
|
|
79
|
-
|
|
223
|
+
|
|
224
|
+
walk(queues)
|
|
80
225
|
end
|
|
81
226
|
|
|
82
227
|
# Called on shutdown for jobs the Processor couldn't finish in time.
|
|
@@ -91,22 +236,30 @@ module Wurk
|
|
|
91
236
|
# in-flight in the private list until the next boot; we prefer the
|
|
92
237
|
# immediate move so a rolling deploy recovers work without a restart.
|
|
93
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
|
|
94
244
|
return if in_progress.nil? || in_progress.empty?
|
|
95
245
|
|
|
96
246
|
config.redis { |conn| requeue_pipelined(conn, in_progress) }
|
|
97
247
|
end
|
|
98
248
|
|
|
99
249
|
# Prefixed queue keys (`queue:<name>`) in fetch order. Strict mode
|
|
100
|
-
# preserves declaration order
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
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.
|
|
105
257
|
def queues_cmd
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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) }
|
|
110
263
|
end
|
|
111
264
|
|
|
112
265
|
# Quiet hook (Manager#quiet). Flips the drain flag so retrieve_work
|
|
@@ -114,12 +267,75 @@ module Wurk
|
|
|
114
267
|
# one sitting in the between-jobs window (Processor#run only re-checks its
|
|
115
268
|
# own @done between iterations). Quiet is one-way — matches Sidekiq TSTP
|
|
116
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.
|
|
117
273
|
def terminate
|
|
118
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' })
|
|
119
281
|
end
|
|
120
282
|
|
|
121
283
|
private
|
|
122
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
|
+
|
|
123
339
|
# One pipelined RELIABLE_REQUEUE EVALSHA per UoW. Mirrors
|
|
124
340
|
# Client#push_batched_pipelined: a pipelined EVALSHA surfaces NOSCRIPT
|
|
125
341
|
# only at finalize (never to eval_cached's inline rescue). Every command
|
|
@@ -131,7 +347,7 @@ module Wurk
|
|
|
131
347
|
in_progress.each do |uow|
|
|
132
348
|
Wurk::Lua::Loader.public_send(
|
|
133
349
|
eval_method, pipe, :reliable_requeue,
|
|
134
|
-
keys: [
|
|
350
|
+
keys: [queue_keys(uow.queue).first, uow.queue],
|
|
135
351
|
argv: [uow.job]
|
|
136
352
|
)
|
|
137
353
|
end
|
|
@@ -143,32 +359,131 @@ module Wurk
|
|
|
143
359
|
requeue_pipelined(conn, in_progress, eval_method: :eval_with_source)
|
|
144
360
|
end
|
|
145
361
|
|
|
146
|
-
# SMEMBERS of the `paused` SET
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
# lookup against the (often
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
152
404
|
end
|
|
153
405
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Both LMOVE forms claim apply-safety, so fetch keeps the full
|
|
438
|
+
# connection-blip backoff the F5 split otherwise takes away: a move that
|
|
439
|
+
# applied but whose reply was lost leaves the job in *this* process's
|
|
440
|
+
# private list, un-ACKed — byte-for-byte the state a SIGKILL between fetch
|
|
441
|
+
# and ack leaves behind, which the next boot's Reaper already reclaims. The
|
|
442
|
+
# replay then pulls a different job; nothing duplicates and nothing is
|
|
443
|
+
# lost, at worst one job waits out this process's lifetime.
|
|
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
|
|
158
465
|
end
|
|
159
466
|
|
|
160
467
|
def blmove(public_q)
|
|
161
|
-
priv =
|
|
468
|
+
priv, name = queue_keys(public_q)
|
|
162
469
|
timeout = poll_interval
|
|
163
470
|
# Dedicated fetch pool, not the main one: a parked BLMOVE holds its slot
|
|
164
471
|
# for the whole block window, so routing it here keeps idle fetchers from
|
|
165
|
-
# starving the main pool's background loops (#101).
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
476
|
+
job = config.fetch_redis(idempotent: true) do |conn|
|
|
477
|
+
conn.blocking_call(timeout, 'BLMOVE', public_q, priv, 'RIGHT', 'LEFT', timeout)
|
|
170
478
|
end
|
|
171
|
-
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)
|
|
172
487
|
end
|
|
173
488
|
|
|
174
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
|
@@ -11,14 +11,17 @@ module Wurk
|
|
|
11
11
|
# Wurk::Launcher so the launcher can stay focused on lifecycle and so the
|
|
12
12
|
# heartbeat schema lives in one place readers can grep for.
|
|
13
13
|
#
|
|
14
|
-
# Each beat is
|
|
14
|
+
# Each beat is two pipelined round-trips. The identity write:
|
|
15
15
|
# SADD processes <identity>
|
|
16
16
|
# HSET <identity> info concurrency busy beat quiet rss rtt_us
|
|
17
17
|
# EXPIRE <identity> 60
|
|
18
18
|
# UNLINK <identity>:work
|
|
19
19
|
# HSET <identity>:work <tid> <json> ... (only if WORK_STATE non-empty)
|
|
20
20
|
# EXPIRE <identity>:work 60 (only if WORK_STATE non-empty)
|
|
21
|
+
# then the signal drain:
|
|
21
22
|
# LPOP <identity>-signals × BEAT_PAUSE
|
|
23
|
+
# Two rather than one because only the first is safe to replay — see
|
|
24
|
+
# #pipelined_beat and #drain_signals.
|
|
22
25
|
#
|
|
23
26
|
# The work hash is UNLINK-then-rewritten on every beat — a dropped beat
|
|
24
27
|
# momentarily empties it, and ProcessSet#cleanup compensates by SREM-ing
|
|
@@ -90,16 +93,17 @@ module Wurk
|
|
|
90
93
|
|
|
91
94
|
private
|
|
92
95
|
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
+
# Every command in #write_beat converges on the state this snapshot
|
|
97
|
+
# describes however many times it lands, so the beat claims apply-safety and
|
|
98
|
+
# rides out a blip the way it did before the pool started splitting replay
|
|
99
|
+
# by it. `rtt_us` measures this write alone — the signal drain that follows
|
|
100
|
+
# is a separate checkout and deliberately not part of the gauge.
|
|
96
101
|
def pipelined_beat
|
|
97
102
|
work_snapshot = Processor::WORK_STATE.dup
|
|
98
|
-
lead = 4 + (work_snapshot.empty? ? 0 : 2)
|
|
99
103
|
t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond)
|
|
100
|
-
|
|
104
|
+
redis(idempotent: true) { |conn| conn.pipelined { |pipe| write_beat(pipe, work_snapshot) } }
|
|
101
105
|
rtt = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond) - t0
|
|
102
|
-
[
|
|
106
|
+
[drain_signals, rtt]
|
|
103
107
|
end
|
|
104
108
|
|
|
105
109
|
def write_beat(pipe, work_snapshot)
|
|
@@ -107,7 +111,6 @@ module Wurk
|
|
|
107
111
|
pipe.call('HSET', @identity, *beat_hash_args(work_snapshot.size))
|
|
108
112
|
pipe.call('EXPIRE', @identity, TTL_SECONDS)
|
|
109
113
|
write_work_hash(pipe, work_snapshot)
|
|
110
|
-
drain_signals(pipe)
|
|
111
114
|
end
|
|
112
115
|
|
|
113
116
|
def beat_hash_args(busy)
|
|
@@ -132,10 +135,19 @@ module Wurk
|
|
|
132
135
|
pipe.call('EXPIRE', work_key, TTL_SECONDS)
|
|
133
136
|
end
|
|
134
137
|
|
|
138
|
+
# Its own checkout, and never an idempotent one: LPOP is destructive and
|
|
139
|
+
# carries its result in the reply, so a replay after a lost reply discards
|
|
140
|
+
# whatever the first attempt already popped — and a discarded entry is a
|
|
141
|
+
# dashboard TERM or TSTP this process never acts on. Fused into the beat
|
|
142
|
+
# pipeline it would have dragged those writes down to the same no-replay
|
|
143
|
+
# default, or worse, invited a later sweep to claim the LPOPs alongside
|
|
144
|
+
# them. Runs after the beat so a signal only leaves Redis once the write
|
|
145
|
+
# that reports us alive has landed.
|
|
146
|
+
#
|
|
135
147
|
# LPOP one entry per second of cadence so a flood of queued signals
|
|
136
148
|
# can't stall the beat; anything older drains on the next beat.
|
|
137
|
-
def drain_signals
|
|
138
|
-
BEAT_PAUSE.times { pipe.call('LPOP', "#{@identity}-signals") }
|
|
149
|
+
def drain_signals
|
|
150
|
+
redis { |conn| conn.pipelined { |pipe| BEAT_PAUSE.times { pipe.call('LPOP', "#{@identity}-signals") } } }
|
|
139
151
|
end
|
|
140
152
|
|
|
141
153
|
def info_hash
|
|
@@ -178,7 +190,7 @@ module Wurk
|
|
|
178
190
|
|
|
179
191
|
def cpu_model
|
|
180
192
|
if ::File.exist?('/proc/cpuinfo')
|
|
181
|
-
line = ::File.
|
|
193
|
+
line = ::File.open('/proc/cpuinfo') { |f| f.find { |l| l.start_with?('model name') } }
|
|
182
194
|
line&.split(':', 2)&.last&.strip
|
|
183
195
|
else
|
|
184
196
|
model = `sysctl -n machdep.cpu.brand_string 2>/dev/null`.strip
|
|
@@ -190,7 +202,7 @@ module Wurk
|
|
|
190
202
|
|
|
191
203
|
def memory_total_kb
|
|
192
204
|
if ::File.exist?('/proc/meminfo')
|
|
193
|
-
line = ::File.
|
|
205
|
+
line = ::File.open('/proc/meminfo') { |f| f.find { |l| l.start_with?('MemTotal') } }
|
|
194
206
|
line.to_s[/\d+/].to_i
|
|
195
207
|
else
|
|
196
208
|
`sysctl -n hw.memsize 2>/dev/null`.to_i / 1024
|
data/lib/wurk/history.rb
CHANGED
|
@@ -71,11 +71,23 @@ module Wurk
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def start
|
|
74
|
-
@thread
|
|
74
|
+
return @thread if @thread
|
|
75
|
+
|
|
76
|
+
@timer.reset
|
|
77
|
+
@thread = safe_thread('history-snapshot') { @timer.run { tick } }
|
|
75
78
|
end
|
|
76
79
|
|
|
80
|
+
# Blocks until the thread is really gone: the launcher releases the cluster
|
|
81
|
+
# lock immediately after this returns, and a snapshot still in flight would
|
|
82
|
+
# race the next leader's first one.
|
|
83
|
+
#
|
|
84
|
+
# Cleared only on a confirmed join (Thread#join returns nil on timeout): a
|
|
85
|
+
# wedged thread must stay tracked so #start's guard returns it instead of
|
|
86
|
+
# calling @timer.reset, which would un-terminate the loop it is still
|
|
87
|
+
# inside and leave two threads writing the same stream.
|
|
77
88
|
def terminate
|
|
78
89
|
@timer.terminate
|
|
90
|
+
@thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
|
|
79
91
|
end
|
|
80
92
|
|
|
81
93
|
# Leader-gated: only the elected leader emits, so N workers don't each
|