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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/lib/wurk/capsule.rb +40 -12
  5. data/lib/wurk/client/buffered.rb +7 -0
  6. data/lib/wurk/client.rb +102 -32
  7. data/lib/wurk/component.rb +34 -2
  8. data/lib/wurk/configuration.rb +45 -6
  9. data/lib/wurk/context.rb +1 -1
  10. data/lib/wurk/engine.rb +41 -1
  11. data/lib/wurk/errors.rb +15 -0
  12. data/lib/wurk/fetcher/reaper.rb +12 -2
  13. data/lib/wurk/fetcher/reliable.rb +322 -60
  14. data/lib/wurk/fetcher.rb +6 -0
  15. data/lib/wurk/heartbeat.rb +2 -2
  16. data/lib/wurk/job_logger.rb +16 -7
  17. data/lib/wurk/job_set.rb +3 -2
  18. data/lib/wurk/job_util.rb +44 -24
  19. data/lib/wurk/launcher.rb +116 -72
  20. data/lib/wurk/leader.rb +36 -6
  21. data/lib/wurk/logger.rb +1 -1
  22. data/lib/wurk/lua/loader.rb +9 -3
  23. data/lib/wurk/metrics/accumulator.rb +95 -0
  24. data/lib/wurk/metrics/flusher.rb +70 -0
  25. data/lib/wurk/metrics/history.rb +98 -29
  26. data/lib/wurk/metrics/statsd.rb +32 -18
  27. data/lib/wurk/middleware/chain.rb +31 -14
  28. data/lib/wurk/middleware/poison_pill.rb +23 -2
  29. data/lib/wurk/pool_checkout.rb +10 -0
  30. data/lib/wurk/processor.rb +79 -11
  31. data/lib/wurk/profiler.rb +6 -2
  32. data/lib/wurk/queue.rb +8 -0
  33. data/lib/wurk/redis_client_adapter.rb +1 -1
  34. data/lib/wurk/redis_pool.rb +2 -1
  35. data/lib/wurk/shutdown_gate.rb +79 -0
  36. data/lib/wurk/stats.rb +5 -1
  37. data/lib/wurk/swarm/child_boot.rb +24 -4
  38. data/lib/wurk/swarm.rb +88 -14
  39. data/lib/wurk/version.rb +1 -1
  40. data/lib/wurk/web/config.rb +11 -7
  41. data/lib/wurk.rb +2 -8
  42. data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
  43. data/vendor/assets/dashboard/assets/{BatchDetail-OmC5NPgw.js → BatchDetail-CUXJUQ3Q.js} +1 -1
  44. data/vendor/assets/dashboard/assets/{Batches-CIpai7St.js → Batches-Cxan6Ngw.js} +1 -1
  45. data/vendor/assets/dashboard/assets/{Busy-A_kwSR6Q.js → Busy-DC5EGM0g.js} +1 -1
  46. data/vendor/assets/dashboard/assets/{Cron-BG7HTqlp.js → Cron-Dlt8tXJA.js} +1 -1
  47. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
  48. data/vendor/assets/dashboard/assets/{Dead-8J21jMyK.js → Dead-dZ7VGlKS.js} +1 -1
  49. data/vendor/assets/dashboard/assets/{Extension-B4Q9FIQu.js → Extension-DaFpEIJf.js} +1 -1
  50. data/vendor/assets/dashboard/assets/{FilterBox-Fh_Ae7UW.js → FilterBox-CO3aYWIq.js} +1 -1
  51. data/vendor/assets/dashboard/assets/{JobDetailModal-Ceng0PMB.js → JobDetailModal-DSWbT6G0.js} +1 -1
  52. data/vendor/assets/dashboard/assets/{Limiters-CruDWvNZ.js → Limiters-Cb4PKXNR.js} +1 -1
  53. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
  54. data/vendor/assets/dashboard/assets/{Modal-CN3rdKA_.js → Modal-B86q6ruL.js} +1 -1
  55. data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
  56. data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
  57. data/vendor/assets/dashboard/assets/{Queues-D86FYohJ.js → Queues-CIyPevOy.js} +1 -1
  58. data/vendor/assets/dashboard/assets/{Retries-Bz1O1D-i.js → Retries-DopwXkXl.js} +1 -1
  59. data/vendor/assets/dashboard/assets/{Scheduled-B6h2akTu.js → Scheduled-1-Z7i1zE.js} +1 -1
  60. data/vendor/assets/dashboard/assets/{Search-OOu22e5s.js → Search-ByA6eTma.js} +1 -1
  61. data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
  62. data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
  63. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
  64. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-dVPGEWzn.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
  65. data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
  66. data/vendor/assets/dashboard/index.html +2 -2
  67. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  68. metadata +30 -26
  69. data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
  70. data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
  71. 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
- # and the capsule we use to reach Redis. ACK removes from the private
43
- # list; requeue pushes back to the public queue head so the job is
44
- # next pulled. LREM count=1 is idempotent for our payloads since
45
- # each job's JSON contains a unique `jid`.
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, keyword_init: true) do
52
- # The counter DEL rides this round trip rather than taking one of its
53
- # own: the ACK is the only Redis call the success path makes, and a
54
- # per-job call would be a fetch+execute regression for the sake of a
55
- # key that exists for roughly no jobs. See Middleware::PoisonPill.
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
- private_list = Reliable.private_queue_name(queue)
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
- def queue_name
70
- 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?
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 so UnitOfWork can compute the private list without
79
- # carrying a back-reference to its parent fetcher. Index defaults to
80
- # 0 — we run one fetcher per capsule today. Multi-processor topology
81
- # (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.
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 (an SMEMBERS per
114
- # pass, on the main pool) as fast as the CPU allows. Mirrors Sidekiq's
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.each do |public_q|
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. Random/weighted shuffle each call —
147
- # `@queues` is pre-expanded by weight in Capsule#queues=, so uniform
148
- # shuffle yields weighted fairness; .uniq trims duplicates. Paused
149
- # queues are filtered after shuffle so the membership test runs on
150
- # 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.
151
257
  def queues_cmd
152
- names = config.mode == :strict ? config.queues : config.queues.shuffle.uniq
153
- paused = paused_names
154
- names = names.reject { |q| paused.include?(q) } unless paused.empty?
155
- 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) }
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: [self.class.private_queue_name(uow.queue), uow.queue],
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. One round-trip per fetch pass; the
193
- # set is tiny in practice (one entry per paused queue) so the cost
194
- # is dominated by the BLMOVE that follows. Returns a Set for O(1)
195
- # lookup against the (often weighted-expanded) queue list.
196
- def paused_names
197
- config.redis(idempotent: true) { |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
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
- def lmove(public_q)
208
- priv = self.class.private_queue_name(public_q)
209
- job = config.redis(idempotent: true) { |conn| conn.call('LMOVE', public_q, priv, 'RIGHT', 'LEFT') }
210
- job ? UnitOfWork.new(queue: public_q, job: job, config: config) : nil
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 = self.class.private_queue_name(public_q)
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). Extend the socket
219
- # read-timeout one second past BLMOVE's own server-side timeout so the
220
- # connection's read timeout can't fire while BLMOVE is legitimately blocked.
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 + 1, 'BLMOVE', public_q, priv, 'RIGHT', 'LEFT', timeout)
477
+ conn.blocking_call(timeout, 'BLMOVE', public_q, priv, 'RIGHT', 'LEFT', timeout)
223
478
  end
224
- 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)
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
@@ -190,7 +190,7 @@ module Wurk
190
190
 
191
191
  def cpu_model
192
192
  if ::File.exist?('/proc/cpuinfo')
193
- 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') } }
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.foreach('/proc/meminfo').find { |l| l.start_with?('MemTotal') }
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
@@ -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 with NX so re-scheduling the same payload doesn't reset its score.
69
- # Mirrors Sidekiq::JobSet#schedule exactly.
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
- case obj
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