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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/lib/wurk/batch/callbacks.rb +82 -12
  5. data/lib/wurk/batch/death_handler.rb +7 -4
  6. data/lib/wurk/batch/server_middleware.rb +1 -1
  7. data/lib/wurk/batch.rb +121 -15
  8. data/lib/wurk/capsule.rb +45 -16
  9. data/lib/wurk/cli.rb +48 -14
  10. data/lib/wurk/client/buffered.rb +200 -43
  11. data/lib/wurk/client.rb +181 -38
  12. data/lib/wurk/compat.rb +1 -1
  13. data/lib/wurk/component.rb +36 -4
  14. data/lib/wurk/configuration.rb +47 -7
  15. data/lib/wurk/context.rb +1 -1
  16. data/lib/wurk/cron.rb +94 -37
  17. data/lib/wurk/deploy.rb +5 -3
  18. data/lib/wurk/embedded.rb +13 -0
  19. data/lib/wurk/engine.rb +41 -1
  20. data/lib/wurk/errors.rb +15 -0
  21. data/lib/wurk/fetcher/reaper.rb +125 -58
  22. data/lib/wurk/fetcher/reliable.rb +366 -51
  23. data/lib/wurk/fetcher.rb +6 -0
  24. data/lib/wurk/heartbeat.rb +24 -12
  25. data/lib/wurk/history.rb +13 -1
  26. data/lib/wurk/job_logger.rb +16 -7
  27. data/lib/wurk/job_set.rb +3 -2
  28. data/lib/wurk/job_util.rb +44 -24
  29. data/lib/wurk/launcher.rb +230 -119
  30. data/lib/wurk/leader.rb +63 -14
  31. data/lib/wurk/limiter/base.rb +8 -10
  32. data/lib/wurk/limiter/bucket.rb +1 -1
  33. data/lib/wurk/limiter/concurrent.rb +27 -22
  34. data/lib/wurk/limiter/window.rb +13 -11
  35. data/lib/wurk/limiter.rb +7 -4
  36. data/lib/wurk/logger.rb +1 -1
  37. data/lib/wurk/lua/loader.rb +9 -3
  38. data/lib/wurk/lua.rb +97 -14
  39. data/lib/wurk/manager.rb +29 -13
  40. data/lib/wurk/metrics/accumulator.rb +95 -0
  41. data/lib/wurk/metrics/flusher.rb +70 -0
  42. data/lib/wurk/metrics/history.rb +102 -32
  43. data/lib/wurk/metrics/queue_rollup.rb +13 -1
  44. data/lib/wurk/metrics/rollup.rb +13 -1
  45. data/lib/wurk/metrics/statsd.rb +32 -18
  46. data/lib/wurk/middleware/chain.rb +31 -14
  47. data/lib/wurk/middleware/interrupt_handler.rb +7 -6
  48. data/lib/wurk/middleware/poison_pill.rb +93 -31
  49. data/lib/wurk/middleware.rb +2 -2
  50. data/lib/wurk/pool_checkout.rb +39 -0
  51. data/lib/wurk/process_set.rb +10 -5
  52. data/lib/wurk/processor.rb +83 -9
  53. data/lib/wurk/profiler.rb +9 -4
  54. data/lib/wurk/queue.rb +18 -7
  55. data/lib/wurk/rails_boot.rb +38 -7
  56. data/lib/wurk/redis_client_adapter.rb +49 -5
  57. data/lib/wurk/redis_pool.rb +71 -25
  58. data/lib/wurk/scheduled.rb +30 -2
  59. data/lib/wurk/shutdown_gate.rb +79 -0
  60. data/lib/wurk/stats.rb +19 -10
  61. data/lib/wurk/swarm/child_boot.rb +36 -4
  62. data/lib/wurk/swarm.rb +258 -43
  63. data/lib/wurk/timer_loop.rb +14 -0
  64. data/lib/wurk/version.rb +1 -1
  65. data/lib/wurk/web/config.rb +11 -7
  66. data/lib/wurk/web/enterprise.rb +58 -6
  67. data/lib/wurk/web/extension.rb +1 -1
  68. data/lib/wurk/web/search.rb +5 -3
  69. data/lib/wurk.rb +12 -10
  70. data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
  75. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
  76. data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
  77. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
  78. data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
  81. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
  82. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
  83. data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
  84. data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
  85. data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
  89. data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
  91. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
  92. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
  93. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +32 -27
  98. data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
  99. data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
  100. data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
  101. data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
  102. data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
  103. 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
- # Carries the public queue key, the raw (still-JSON) job payload,
34
- # and the capsule we use to reach Redis. ACK removes from the private
35
- # list; requeue pushes back to the public queue head so the job is
36
- # next pulled. LREM count=1 is idempotent for our payloads since
37
- # each job's JSON contains a unique `jid`.
38
- UnitOfWork = Struct.new(:queue, :job, :config, keyword_init: true) do
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
- config.redis do |conn|
41
- conn.call('LREM', Reliable.private_queue_name(queue), 1, job)
42
- end
81
+ fetcher.defer_ack(self)
43
82
  end
44
83
 
45
- def queue_name
46
- queue.delete_prefix(Keys::QUEUE_PREFIX)
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 so UnitOfWork can compute the private list without
55
- # carrying a back-reference to its parent fetcher. Index defaults to
56
- # 0 — we run one fetcher per capsule today. Multi-processor topology
57
- # (one private list per processor slot) is a future Manager concern.
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
- return nil if @done
208
+ if @done
209
+ flush_pending_acks
210
+ sleep QUIET_PAUSE
211
+ return nil
212
+ end
71
213
 
72
214
  queues = queues_cmd
73
- return nil if queues.empty?
74
-
75
- queues.each do |public_q|
76
- uow = lmove(public_q)
77
- return uow if uow
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
- blmove(queues.first)
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. Random/weighted shuffle each call —
101
- # `@queues` is pre-expanded by weight in Capsule#queues=, so uniform
102
- # shuffle yields weighted fairness; .uniq trims duplicates. Paused
103
- # queues are filtered after shuffle so the membership test runs on
104
- # the smallest possible set.
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
- names = config.mode == :strict ? config.queues : config.queues.shuffle.uniq
107
- paused = paused_names
108
- names = names.reject { |q| paused.include?(q) } unless paused.empty?
109
- names.map { |q| "#{Keys::QUEUE_PREFIX}#{q}" }
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: [self.class.private_queue_name(uow.queue), uow.queue],
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. One round-trip per fetch pass; the
147
- # set is tiny in practice (one entry per paused queue) so the cost
148
- # is dominated by the BLMOVE that follows. Returns a Set for O(1)
149
- # lookup against the (often weighted-expanded) queue list.
150
- def paused_names
151
- config.redis { |conn| conn.call('SMEMBERS', Keys::PAUSED_SET) }.to_set
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
- def lmove(public_q)
155
- priv = self.class.private_queue_name(public_q)
156
- job = config.redis { |conn| conn.call('LMOVE', public_q, priv, 'RIGHT', 'LEFT') }
157
- job ? UnitOfWork.new(queue: public_q, job: job, config: config) : nil
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 = self.class.private_queue_name(public_q)
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). Extend the socket
166
- # read-timeout one second past BLMOVE's own server-side timeout so the
167
- # connection's read timeout can't fire while BLMOVE is legitimately blocked.
168
- job = config.fetch_redis do |conn|
169
- conn.blocking_call(timeout + 1, 'BLMOVE', public_q, priv, 'RIGHT', 'LEFT', timeout)
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 ? UnitOfWork.new(queue: public_q, job: job, config: config) : nil
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
@@ -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 one pipelined round-trip:
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
- # Two extra writes (HSET + EXPIRE for the work mirror) when WORK_STATE
94
- # has entries, so `lead` shifts to skip them when slicing signals out
95
- # of the pipeline result.
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
- results = redis { |conn| conn.pipelined { |pipe| write_beat(pipe, work_snapshot) } }
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
- [results[lead, BEAT_PAUSE] || [], rtt]
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(pipe)
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.foreach('/proc/cpuinfo').find { |l| l.start_with?('model name') }
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.foreach('/proc/meminfo').find { |l| l.start_with?('MemTotal') }
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 ||= safe_thread('history-snapshot') { @timer.run { tick } } # rubocop:disable Naming/MemoizedInstanceVariableName
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