wurk 1.7.6 → 1.8.1

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 (217) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -5
  3. data/app/controllers/concerns/wurk/sse_streaming.rb +2 -1
  4. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +33 -9
  5. data/app/controllers/wurk/api/pagination.rb +25 -7
  6. data/app/controllers/wurk/api_controller.rb +49 -25
  7. data/app/controllers/wurk/extensions_controller.rb +8 -2
  8. data/app/controllers/wurk/profiles_controller.rb +45 -9
  9. data/config/routes.rb +31 -10
  10. data/lib/generators/wurk/install/templates/wurk.rb +1 -1
  11. data/lib/sidekiq/capsule.rb +4 -0
  12. data/lib/sidekiq/component.rb +4 -0
  13. data/lib/sidekiq/config.rb +4 -0
  14. data/lib/sidekiq/deploy.rb +4 -0
  15. data/lib/sidekiq/embedded.rb +4 -0
  16. data/lib/sidekiq/fetch.rb +4 -0
  17. data/lib/sidekiq/iterable_job.rb +4 -0
  18. data/lib/sidekiq/job/interrupt_handler.rb +4 -0
  19. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +4 -0
  20. data/lib/sidekiq/job/iterable/csv_enumerator.rb +4 -0
  21. data/lib/sidekiq/job/iterable.rb +4 -0
  22. data/lib/sidekiq/job_logger.rb +4 -0
  23. data/lib/sidekiq/job_util.rb +4 -0
  24. data/lib/sidekiq/loader.rb +4 -0
  25. data/lib/sidekiq/logger.rb +4 -0
  26. data/lib/sidekiq/metrics/query.rb +4 -0
  27. data/lib/sidekiq/middleware/current_attributes.rb +7 -0
  28. data/lib/sidekiq/middleware/i18n.rb +6 -0
  29. data/lib/sidekiq/middleware/modules.rb +4 -0
  30. data/lib/sidekiq/pro/web.rb +5 -0
  31. data/lib/sidekiq/profiler.rb +4 -0
  32. data/lib/sidekiq/redis_client_adapter.rb +4 -0
  33. data/lib/sidekiq/test_api.rb +5 -0
  34. data/lib/sidekiq/testing/inline.rb +8 -0
  35. data/lib/sidekiq/transaction_aware_client.rb +4 -0
  36. data/lib/sidekiq/worker_compatibility_alias.rb +4 -0
  37. data/lib/sidekiq-ent/periodic/testing.rb +5 -0
  38. data/lib/sidekiq-ent/web.rb +5 -0
  39. data/lib/sidekiq-ent.rb +7 -0
  40. data/lib/sidekiq-pro.rb +7 -0
  41. data/lib/wurk/api/fast.rb +8 -33
  42. data/lib/wurk/api/idempotency.rb +28 -5
  43. data/lib/wurk/api/swarm.rb +1 -1
  44. data/lib/wurk/batch/callbacks.rb +143 -92
  45. data/lib/wurk/batch/death_handler.rb +19 -8
  46. data/lib/wurk/batch/server_middleware.rb +37 -24
  47. data/lib/wurk/batch/status.rb +49 -15
  48. data/lib/wurk/batch.rb +164 -65
  49. data/lib/wurk/capsule.rb +0 -2
  50. data/lib/wurk/client/buffered.rb +227 -106
  51. data/lib/wurk/client.rb +33 -13
  52. data/lib/wurk/compat.rb +13 -0
  53. data/lib/wurk/configuration.rb +155 -24
  54. data/lib/wurk/cpu_count.rb +97 -0
  55. data/lib/wurk/cron.rb +97 -34
  56. data/lib/wurk/dead_set.rb +6 -7
  57. data/lib/wurk/encryption.rb +46 -13
  58. data/lib/wurk/fetcher/many_queues.rb +65 -0
  59. data/lib/wurk/fetcher/orphan_grace.rb +67 -0
  60. data/lib/wurk/fetcher/private_list_key.rb +76 -0
  61. data/lib/wurk/fetcher/reaper.rb +55 -74
  62. data/lib/wurk/fetcher/reliable.rb +23 -17
  63. data/lib/wurk/fetcher/unit_of_work.rb +19 -1
  64. data/lib/wurk/fetcher/unparseable_keys.rb +56 -0
  65. data/lib/wurk/flow/completion.rb +28 -3
  66. data/lib/wurk/flow/creation.rb +24 -4
  67. data/lib/wurk/flow.rb +24 -2
  68. data/lib/wurk/health.rb +182 -40
  69. data/lib/wurk/heartbeat.rb +37 -14
  70. data/lib/wurk/history.rb +7 -1
  71. data/lib/wurk/import/sidekiq_cron.rb +248 -0
  72. data/lib/wurk/job/options.rb +22 -3
  73. data/lib/wurk/job_logger.rb +13 -3
  74. data/lib/wurk/job_record.rb +68 -19
  75. data/lib/wurk/job_retry.rb +12 -6
  76. data/lib/wurk/job_set.rb +99 -51
  77. data/lib/wurk/job_util.rb +27 -8
  78. data/lib/wurk/launcher.rb +84 -40
  79. data/lib/wurk/leader.rb +53 -28
  80. data/lib/wurk/limiter/base.rb +11 -30
  81. data/lib/wurk/limiter/bucket.rb +14 -5
  82. data/lib/wurk/limiter/server_middleware.rb +2 -1
  83. data/lib/wurk/limiter.rb +1 -1
  84. data/lib/wurk/loader.rb +48 -0
  85. data/lib/wurk/lua/batch_ack_complete.lua +28 -0
  86. data/lib/wurk/lua/batch_ack_failed.lua +18 -0
  87. data/lib/wurk/lua/batch_ack_success.lua +35 -0
  88. data/lib/wurk/lua/batch_append_callback.lua +66 -0
  89. data/lib/wurk/lua/batch_delete.lua +31 -0
  90. data/lib/wurk/lua/batch_hold.lua +17 -0
  91. data/lib/wurk/lua/batch_invalidate.lua +14 -0
  92. data/lib/wurk/lua/batch_kid_done.lua +14 -0
  93. data/lib/wurk/lua/batch_push.lua +47 -0
  94. data/lib/wurk/lua/batch_remove_jobs.lua +29 -0
  95. data/lib/wurk/lua/batch_schedule.lua +27 -0
  96. data/lib/wurk/lua/fetch_first.lua +27 -0
  97. data/lib/wurk/lua/flow_abandon.lua +16 -21
  98. data/lib/wurk/lua/flow_advance.lua +41 -26
  99. data/lib/wurk/lua/flow_create.lua +24 -9
  100. data/lib/wurk/lua/flow_fail.lua +3 -3
  101. data/lib/wurk/lua/leader_campaign.lua +19 -0
  102. data/lib/wurk/lua/limiter_bucket_acquire.lua +3 -2
  103. data/lib/wurk/lua/limiter_list_sweep.lua +8 -9
  104. data/lib/wurk/lua/loader.rb +36 -23
  105. data/lib/wurk/lua/throttle_slot.lua +13 -26
  106. data/lib/wurk/lua.rb +31 -244
  107. data/lib/wurk/manager.rb +25 -18
  108. data/lib/wurk/metrics/accumulator.rb +31 -10
  109. data/lib/wurk/metrics/dashboard_series.rb +163 -0
  110. data/lib/wurk/metrics/histogram.rb +46 -0
  111. data/lib/wurk/metrics/history.rb +49 -57
  112. data/lib/wurk/metrics/prometheus.rb +232 -0
  113. data/lib/wurk/metrics/query.rb +127 -155
  114. data/lib/wurk/metrics/rollup.rb +2 -2
  115. data/lib/wurk/metrics/statsd.rb +1 -1
  116. data/lib/wurk/middleware/interrupt_handler.rb +6 -2
  117. data/lib/wurk/middleware/poison_pill.rb +5 -4
  118. data/lib/wurk/middleware/timeout.rb +8 -8
  119. data/lib/wurk/process_set.rb +5 -5
  120. data/lib/wurk/processor.rb +69 -31
  121. data/lib/wurk/profile_set.rb +19 -24
  122. data/lib/wurk/profiler.rb +48 -50
  123. data/lib/wurk/queue.rb +36 -19
  124. data/lib/wurk/rails_boot.rb +43 -1
  125. data/lib/wurk/railtie.rb +2 -0
  126. data/lib/wurk/rake_tasks.rb +18 -0
  127. data/lib/wurk/redact.rb +39 -0
  128. data/lib/wurk/redis_connection.rb +6 -3
  129. data/lib/wurk/redis_options.rb +21 -4
  130. data/lib/wurk/redis_pool.rb +119 -21
  131. data/lib/wurk/scheduled.rb +44 -11
  132. data/lib/wurk/sentry/error_handler.rb +53 -7
  133. data/lib/wurk/sentry/middleware.rb +7 -8
  134. data/lib/wurk/shutdown_gate.rb +24 -1
  135. data/lib/wurk/sorted_entry.rb +16 -5
  136. data/lib/wurk/stats.rb +9 -4
  137. data/lib/wurk/status.rb +2 -2
  138. data/lib/wurk/swarm/child_boot.rb +48 -38
  139. data/lib/wurk/swarm/liveness.rb +153 -0
  140. data/lib/wurk/swarm/restart.rb +24 -3
  141. data/lib/wurk/swarm.rb +115 -24
  142. data/lib/wurk/telemetry/server_middleware.rb +2 -5
  143. data/lib/wurk/testing.rb +15 -5
  144. data/lib/wurk/throttle.rb +16 -1
  145. data/lib/wurk/topology.rb +1 -5
  146. data/lib/wurk/unique.rb +1 -1
  147. data/lib/wurk/version.rb +1 -1
  148. data/lib/wurk/watchdog.rb +2 -2
  149. data/lib/wurk/web/config.rb +56 -20
  150. data/lib/wurk/web/enterprise.rb +21 -18
  151. data/lib/wurk/web/extension.rb +98 -22
  152. data/lib/wurk/web/rack_app.rb +12 -11
  153. data/lib/wurk/web/search.rb +18 -8
  154. data/lib/wurk/worker/setter.rb +17 -14
  155. data/lib/wurk/worker.rb +6 -67
  156. data/lib/wurk.rb +2 -3
  157. data/vendor/assets/dashboard/assets/ArgsValue-_MYkd_vA.js +1 -0
  158. data/vendor/assets/dashboard/assets/BatchDetail-DPmg-hle.js +1 -0
  159. data/vendor/assets/dashboard/assets/Batches-NTgqSlvD.js +1 -0
  160. data/vendor/assets/dashboard/assets/Busy-2rxcLJMq.js +1 -0
  161. data/vendor/assets/dashboard/assets/Cron-BFpJgKYb.js +1 -0
  162. data/vendor/assets/dashboard/assets/Dashboard-C8VOE2S7.js +1 -0
  163. data/vendor/assets/dashboard/assets/Dead-Dse2YEbZ.js +1 -0
  164. data/vendor/assets/dashboard/assets/Extension-BIc2c1zG.js +1 -0
  165. data/vendor/assets/dashboard/assets/FilterBox-BfZm8ZzH.js +1 -0
  166. data/vendor/assets/dashboard/assets/FlowDetail-BWddOtgf.js +1 -0
  167. data/vendor/assets/dashboard/assets/FlowState-6lGsojxE.js +1 -0
  168. data/vendor/assets/dashboard/assets/Flows-BgKTlYwI.js +1 -0
  169. data/vendor/assets/dashboard/assets/JobDetailModal-BrjmO98c.js +2 -0
  170. data/vendor/assets/dashboard/assets/Limiters-DeUkfbcg.js +1 -0
  171. data/vendor/assets/dashboard/assets/Metrics-DzmAgBFG.js +1 -0
  172. data/vendor/assets/dashboard/assets/NotFound-CQLLXIfa.js +1 -0
  173. data/vendor/assets/dashboard/assets/PageHeader-CeSaqysY.js +1 -0
  174. data/vendor/assets/dashboard/assets/Profiles-CI2i2tl8.js +1 -0
  175. data/vendor/assets/dashboard/assets/Queues-DUCQ_3St.js +1 -0
  176. data/vendor/assets/dashboard/assets/Retries-Dc2RFuTf.js +1 -0
  177. data/vendor/assets/dashboard/assets/Scheduled-B8zOKCLC.js +1 -0
  178. data/vendor/assets/dashboard/assets/Search-x0eS-V-U.js +1 -0
  179. data/vendor/assets/dashboard/assets/charts-VggSMFGc.js +1 -0
  180. data/vendor/assets/dashboard/assets/i18n-B6AbnmJn.js +1 -0
  181. data/vendor/assets/dashboard/assets/index-CTWV8VL4.js +141 -0
  182. data/vendor/assets/dashboard/assets/index-WpZjU076.css +1 -0
  183. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-BsYWeStI.js +1 -0
  184. data/vendor/assets/dashboard/assets/useSort-BWWfpnZH.js +1 -0
  185. data/vendor/assets/dashboard/assets/utils-DnzJXljj.js +1 -0
  186. data/vendor/assets/dashboard/index.html +3 -3
  187. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  188. metadata +90 -34
  189. data/config/locales/en.yml +0 -15
  190. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +0 -1
  191. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +0 -1
  192. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +0 -1
  193. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +0 -1
  194. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +0 -1
  195. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +0 -1
  196. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +0 -1
  197. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +0 -1
  198. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +0 -1
  199. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +0 -1
  200. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +0 -1
  201. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +0 -1
  202. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +0 -2
  203. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +0 -1
  204. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +0 -1
  205. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +0 -1
  206. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +0 -1
  207. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +0 -1
  208. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +0 -1
  209. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +0 -1
  210. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +0 -1
  211. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +0 -1
  212. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +0 -1
  213. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +0 -141
  214. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +0 -1
  215. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +0 -1
  216. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +0 -1
  217. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +0 -1
data/lib/wurk/dead_set.rb CHANGED
@@ -23,22 +23,21 @@ module Wurk
23
23
 
24
24
  # Two-axis trim: `ZREMRANGEBYSCORE` evicts entries older than
25
25
  # `dead_timeout_in_seconds`, `ZREMRANGEBYRANK 0 -dead_max_jobs` keeps
26
- # the count bounded. Pipelined — partial failure leaves at most one
27
- # axis applied (acceptable; trim is non-critical, runs again next kill).
26
+ # the count bounded. One MULTI, as upstream — both axes apply together.
28
27
  #
29
28
  # `max_jobs:` / `timeout:` override the global config for this call.
30
29
  # Lets parallel tests run trim with isolated limits without mutating
31
30
  # `Wurk.configuration` (which is process-global and races across threads).
32
- def trim(max_jobs: nil, timeout: nil) # rubocop:disable Naming/PredicateMethod
31
+ def trim(max_jobs: nil, timeout: nil)
33
32
  config = Wurk.configuration
34
33
  max_jobs ||= config[:dead_max_jobs] || 10_000
35
34
  timeout ||= config[:dead_timeout_in_seconds] || (180 * 24 * 60 * 60)
36
35
  cutoff = ::Process.clock_gettime(::Process::CLOCK_REALTIME) - timeout
37
36
 
38
37
  Wurk.redis do |conn|
39
- conn.pipelined do |pipe|
40
- pipe.call('ZREMRANGEBYSCORE', @name, '-inf', "(#{cutoff}")
41
- pipe.call('ZREMRANGEBYRANK', @name, 0, -max_jobs)
38
+ conn.multi do |tx|
39
+ tx.call('ZREMRANGEBYSCORE', @name, '-inf', "(#{cutoff}")
40
+ tx.call('ZREMRANGEBYRANK', @name, 0, -max_jobs)
42
41
  end
43
42
  end
44
43
  true
@@ -65,7 +64,7 @@ module Wurk
65
64
  # synthesized RuntimeError when callers don't have one) — death handlers
66
65
  # receive `(job, ex)`. `max_jobs:` / `timeout:` propagate to the auto-trim;
67
66
  # see `#trim` for the rationale.
68
- def kill(message, opts = {}) # rubocop:disable Naming/PredicateMethod
67
+ def kill(message, opts = {})
69
68
  notify = opts.fetch(:notify_failure, true)
70
69
  do_trim = opts.fetch(:trim, true)
71
70
  ex = opts[:ex] || RuntimeError.new(API_KILL_MESSAGE).tap { |e| e.set_backtrace(caller) }
@@ -34,7 +34,7 @@ module Wurk
34
34
  # end
35
35
  #
36
36
  # Wire format (per docs/target/sidekiq-ent.md §4.4): the last arg becomes
37
- # a plain JSON Hash `{"v"=>N, "iv"=>b64(iv), "ct"=>b64(ct), "tag"=>b64(tag)}`
37
+ # a plain JSON Hash `{"__wurk_enc__"=>true, "v"=>N, "iv"=>…, "ct"=>…, "tag"=>…}`
38
38
  # — *not* a base64 blob of a binary envelope — so the args array stays
39
39
  # valid JSON for inspectors that don't know about encryption.
40
40
  #
@@ -89,7 +89,7 @@ module Wurk
89
89
  #
90
90
  # Idempotent: re-calling rebinds the resolver and rebuilds the cache.
91
91
  # Middleware is installed at most once per chain.
92
- def enable(active_version:, &resolver) # rubocop:disable Naming/PredicateMethod
92
+ def enable(active_version:, &resolver)
93
93
  raise ArgumentError, 'active_version is required' unless active_version
94
94
  raise ArgumentError, 'block returning the key bytes is required' unless resolver
95
95
 
@@ -117,7 +117,7 @@ module Wurk
117
117
  raise Error, 'Wurk::Encryption not enabled' unless enabled?
118
118
 
119
119
  @key_cache ||= {}
120
- @key_cache[version] ||= validate_key!(version, @resolver.call(version))
120
+ @key_cache[version] ||= validate_key!(version, resolve(version))
121
121
  end
122
122
 
123
123
  # Encrypt `value` (any JSON-serializable Ruby value) under
@@ -167,8 +167,27 @@ module Wurk
167
167
  value.key?('v') && value.key?('iv') && value.key?('ct') && value.key?('tag')
168
168
  end
169
169
 
170
+ # `args` with its last element enveloped, unless it already is one. The
171
+ # single sealing rule shared by the client middleware and every server
172
+ # path that writes a payload back to Redis (InterruptHandler, limiter
173
+ # dead-routing): a job hash seen mid-chain may hold decrypted args.
174
+ def seal_args(args)
175
+ return args unless args.is_a?(::Array) && !args.empty? && !envelope?(args.last)
176
+
177
+ args[0..-2] + [encrypt(args.last)]
178
+ end
179
+
180
+ # `job` safe to write back to Redis: a copy with the last arg re-sealed
181
+ # when crypto is on and the job opted in, otherwise `job` itself.
182
+ def seal(job)
183
+ return job unless enabled? && job['encrypt']
184
+
185
+ sealed = seal_args(job['args'])
186
+ sealed.equal?(job['args']) ? job : job.merge('args' => sealed)
187
+ end
188
+
170
189
  # Web UI display helper (§4.7). Given a job hash, returns the args
171
- # array with the last element replaced by the literal `"<encrypted>"`
190
+ # array with the last element replaced by the literal `"[encrypted data]"`
172
191
  # when the job opted in. Cleartext preceding args are untouched so
173
192
  # operators can still triage on user_id / object_id / etc.
174
193
  #
@@ -181,7 +200,7 @@ module Wurk
181
200
  return args if args.empty?
182
201
  return args unless job['encrypt'] || job[:encrypt] || envelope?(args.last)
183
202
 
184
- args[0..-2] + ['<encrypted>']
203
+ args[0..-2] + ['[encrypted data]']
185
204
  end
186
205
 
187
206
  # A decryption failure means the key is gone (rotated away) or the
@@ -212,6 +231,16 @@ module Wurk
212
231
  cipher
213
232
  end
214
233
 
234
+ # A resolver built on `keys.fetch(v)` / `ENV.fetch(...)` raises KeyError
235
+ # (an IndexError) for a rotated-away version where a Hash#[] resolver
236
+ # returns nil. Both mean "no key for this version", so both must take
237
+ # the same dead-set path; a bare KeyError would bubble into 25 retries.
238
+ def resolve(version)
239
+ @resolver.call(version)
240
+ rescue ::IndexError => e
241
+ raise KeyMissingError, "key resolver has no key for version #{version} (#{e.class}: #{e.message})"
242
+ end
243
+
215
244
  def validate_key!(version, bytes)
216
245
  raise KeyMissingError, "key resolver returned nil for version #{version}" if bytes.nil?
217
246
 
@@ -240,17 +269,16 @@ module Wurk
240
269
  include Wurk::Middleware::ClientMiddleware
241
270
 
242
271
  def call(_worker, job, _queue, _redis_pool)
243
- return yield unless Wurk::Encryption.enabled? && job['encrypt']
244
-
245
- args = job['args']
246
- if args.is_a?(::Array) && !args.empty? && !Wurk::Encryption.envelope?(args.last)
247
- job['args'] = args[0..-2] + [Wurk::Encryption.encrypt(args.last)]
248
- end
272
+ job['args'] = Wurk::Encryption.seal_args(job['args']) if Wurk::Encryption.enabled? && job['encrypt']
249
273
  yield
250
274
  end
251
275
  end
252
276
 
253
- # Server middleware — peels the envelope before perform runs. A decrypt
277
+ # Server middleware — peels the envelope before perform runs and puts it
278
+ # back once the chain unwinds, success or failure. The job hash is shared
279
+ # with every outer middleware and the processor; leaving plaintext in it
280
+ # let any of them write the secret back to Redis (InterruptHandler's
281
+ # re-push, limiter dead-routing) or hand it to an error reporter. A decrypt
254
282
  # failure (missing/rotated key, bad tag) is terminal and non-retryable,
255
283
  # so rather than let it bubble into the 25× retry pipeline we route the
256
284
  # job straight to the dead set tagged `encryption_error` and ACK it via
@@ -262,8 +290,13 @@ module Wurk
262
290
  def call(_worker, job, _queue)
263
291
  return yield unless Wurk::Encryption.enabled? && job['encrypt']
264
292
 
293
+ sealed = job['args']
265
294
  decrypt_last_arg!(job)
266
- yield
295
+ begin
296
+ yield
297
+ ensure
298
+ job['args'] = sealed
299
+ end
267
300
  end
268
301
 
269
302
  private
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lua'
4
+ require_relative '../pid_cache'
5
+
6
+ module Wurk
7
+ class Fetcher
8
+ # The rest of {Fetcher::Reliable#walk}'s non-blocking pass once the first
9
+ # queue came back empty: every remaining queue in one round trip
10
+ # (lib/wurk/lua/fetch_first.lua) instead of one LMOVE per queue. A sparse
11
+ # 100-queue capsule used to pay 100 round trips per pass to reach a job on
12
+ # its last queue; it now pays two. The script walks the order it is given,
13
+ # so strict order, the weighted shuffle and the paused filter all stay
14
+ # Reliable#queues_cmd's.
15
+ #
16
+ # Why not the first queue too, for one round trip: a script pays Redis per
17
+ # key it is handed, before it moves anything — measured on Redis 7.0 at
18
+ # ~13us server time for 10 queues and ~83us for 100, against ~0.2us for a
19
+ # bare LMOVE. A capsule whose first queue is busy is the common shape, and
20
+ # folding that queue into the script made its every fetch 1.4x slower
21
+ # end to end on 10 queues. Leading with the LMOVE keeps that path exactly
22
+ # where it was and spends the script only on a pass that already missed.
23
+ module ManyQueues
24
+ NO_ARGV = [].freeze
25
+
26
+ private
27
+
28
+ # Queues 2..n. Two queues is one more LMOVE — a one-key script costs more
29
+ # than the move it would wrap.
30
+ #
31
+ # Apply-safe for the reason #lmove is: a claim whose reply was lost left
32
+ # the job in this process's private list, un-ACKed, which the next boot's
33
+ # Reaper reclaims, and the replay claims a different job.
34
+ def claim_rest(queues)
35
+ return lmove(queues[1]) if queues.size == 2
36
+
37
+ keys = claim_keys(queues)
38
+ index, job = config.redis(idempotent: true) do |conn|
39
+ Wurk::Lua::Loader.eval_cached(conn, :fetch_first, keys: keys, argv: NO_ARGV)
40
+ end
41
+ return nil unless job
42
+
43
+ public_q = queues[index]
44
+ priv, name = queue_keys(public_q)
45
+ unit_of_work(public_q, priv, name, job)
46
+ end
47
+
48
+ # `[public, private, public, private, ...]` for queues 2..n, fetch_first's
49
+ # KEYS. Strict mode with nothing paused hands #walk the same frozen array
50
+ # every pass, so the steady state reuses one build; a shuffled or filtered
51
+ # list is a fresh array per pass and rebuilds. Keyed on the pid too, for
52
+ # the reason #queue_keys is. Copy-on-write: processor threads share this
53
+ # fetcher.
54
+ def claim_keys(queues)
55
+ pid = PidCache.pid
56
+ cached = @claim_keys
57
+ return cached[2] if cached && cached[0].equal?(queues) && cached[1] == pid
58
+
59
+ keys = queues.drop(1).flat_map { |public_q| [public_q, queue_keys(public_q).first] }.freeze
60
+ @claim_keys = [queues, pid, keys].freeze
61
+ keys
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ class Fetcher
5
+ # The Reaper's boot-grace check: has this heartbeat-judged orphan been
6
+ # left alone for at least `grace` seconds? A booting worker can claim a job
7
+ # a moment before its first heartbeat lands, and draining its list in that
8
+ # window runs the job twice, concurrently.
9
+ #
10
+ # The answer comes from OBJECT IDLETIME, which a live owner resets on every
11
+ # claim and ACK. Some servers refuse it — Redis under an LFU maxmemory
12
+ # policy, Dragonfly with no OBJECT at all — and the grace must not quietly
13
+ # vanish with it. Without IDLETIME the clock is this reaper's own: a list
14
+ # is drained only once it has been judged orphaned for `grace` seconds
15
+ # across sweeps, by which time a booting owner has beaten and the
16
+ # heartbeat re-check spares it. The switch is logged once.
17
+ class OrphanGrace
18
+ NO_IDLETIME_WARNING = 'reaper: OBJECT IDLETIME is unavailable on this Redis (%s); the boot grace ' \
19
+ 'before reclaiming a private list now runs on this process\'s own clock, so an ' \
20
+ 'orphan is drained one sweep later than it would be. Expected under an LFU ' \
21
+ 'maxmemory policy or on servers without OBJECT (e.g. Dragonfly).'
22
+
23
+ # Lists remembered for the fallback clock. A list leaves on the pass
24
+ # that clears it; one judged orphaned and then found alive can linger,
25
+ # so the memory is bounded and simply restarts when full — every clock
26
+ # restarting is the conservative direction.
27
+ MEMORY = 10_000
28
+
29
+ def initialize(config, grace)
30
+ @config = config
31
+ @grace = grace
32
+ @idletime = true
33
+ @first_seen = {}
34
+ end
35
+
36
+ # true while the list must be left alone.
37
+ def recent?(key)
38
+ return false unless @grace.positive?
39
+ return local_recent?(key) unless @idletime
40
+
41
+ idle = @config.redis(idempotent: true) { |c| c.call('OBJECT', 'IDLETIME', key) }
42
+ # nil: the list is already gone, nothing to drain.
43
+ idle.nil? || idle < @grace
44
+ rescue RedisClient::CommandError => e
45
+ idletime_unavailable!(e)
46
+ local_recent?(key)
47
+ end
48
+
49
+ private
50
+
51
+ def local_recent?(key)
52
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
53
+ @first_seen.clear if @first_seen.size >= MEMORY && !@first_seen.key?(key)
54
+ seen = (@first_seen[key] ||= now)
55
+ return true if now - seen < @grace
56
+
57
+ @first_seen.delete(key)
58
+ false
59
+ end
60
+
61
+ def idletime_unavailable!(error)
62
+ @idletime = false
63
+ @config.logger.warn { format(NO_IDLETIME_WARNING, error.message.lines.first&.strip) }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../keys'
4
+
5
+ module Wurk
6
+ class Fetcher
7
+ # Reads the owner back out of a reliable-fetch private list key
8
+ # (`queue:<public>|<host>|<pid>|<nonce>|<idx>`, see
9
+ # Reliable.private_queue_name) for the Reaper's liveness check. Pure string
10
+ # work, no Redis.
11
+ module PrivateListKey
12
+ module_function
13
+
14
+ # `queue:<public>|<host>|<pid>|<nonce>|<idx>` → [public_q, host, pid,
15
+ # nonce], parsed from the right so a `|` inside the queue name is
16
+ # tolerated. nil when the key isn't a well-formed private list.
17
+ #
18
+ # With no known prefix to split on, both owner shapes are tried in
19
+ # preference order and the first one leaving a real public queue behind
20
+ # wins. The narrow reading is what saves a pre-nonce key from an all-digit
21
+ # host (`queue:q|123456789012|<pid>|<idx>` — a bare Docker hostname is 12
22
+ # hex chars): read wide, its host segment eats the whole queue name.
23
+ def parse_full(key)
24
+ parts = key.split('|')
25
+ owner_tails(parts).each do |host, pid, nonce, width|
26
+ public_q = parts[0...-width].join('|')
27
+ next unless public_q.start_with?(Keys::QUEUE_PREFIX) && public_q != Keys::QUEUE_PREFIX
28
+
29
+ return [public_q, host, pid, nonce]
30
+ end
31
+ nil
32
+ end
33
+
34
+ # `<public_q>|<host>|<pid>|<nonce>|<idx>` → [host, pid, nonce] (pid as
35
+ # Integer), or all-nil when the suffix isn't a well-formed owner tail.
36
+ # Splitting the suffix off the known public-queue prefix tolerates a `|`
37
+ # inside the queue name itself, and leaves the tail unambiguous: exactly
38
+ # 4 segments for the current shape, exactly 3 for the pre-nonce one.
39
+ def parse_owner(public_q, key)
40
+ suffix = key.delete_prefix("#{public_q}|")
41
+ return [nil, nil, nil] if suffix == key
42
+
43
+ host, pid, nonce = owner_tails(suffix.split('|')).first
44
+ [host, pid, nonce]
45
+ end
46
+
47
+ # Owner segments of a private-list key, taken from the right, as
48
+ # [host, pid, nonce, segment_count] readings in preference order (empty
49
+ # when nothing parses). The wide shape is preferred: an all-digit nonce is
50
+ # rare but reachable, and reading such a key narrow would take the pid for
51
+ # the host and the nonce for the pid — draining a live owner's list out
52
+ # from under it.
53
+ def owner_tails(parts)
54
+ return [] unless parts.size >= 3 && integer?(parts[-1])
55
+
56
+ [wide_tail(parts), narrow_tail(parts)].compact
57
+ end
58
+
59
+ # `<host>|<pid>|<nonce>|<idx>` — the shape every current process writes.
60
+ def wide_tail(parts)
61
+ [parts[-4], parts[-3].to_i, parts[-2], 4] if parts.size >= 4 && integer?(parts[-3])
62
+ end
63
+
64
+ # `<host>|<pid>|<idx>` — written before the nonce existed. Such a list can
65
+ # still hold a pre-upgrade process's in-flight jobs across a rolling
66
+ # upgrade, so it stays reclaimable even though nothing writes it anymore.
67
+ def narrow_tail(parts)
68
+ [parts[-3], parts[-2].to_i, nil, 3] if integer?(parts[-2])
69
+ end
70
+
71
+ def integer?(str)
72
+ str.is_a?(String) && str.match?(/\A\d+\z/)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -4,6 +4,9 @@ require_relative '../component'
4
4
  require_relative '../keys'
5
5
  require_relative '../middleware/poison_pill'
6
6
  require_relative '../timer_loop'
7
+ require_relative 'private_list_key'
8
+ require_relative 'unparseable_keys'
9
+ require_relative 'orphan_grace'
7
10
 
8
11
  module Wurk
9
12
  class Fetcher
@@ -30,6 +33,15 @@ module Wurk
30
33
  # `processes` member (one whose `info` hash still exists). Such reclaim
31
34
  # therefore waits out the 60s heartbeat TTL, exactly as the spec says.
32
35
  #
36
+ # The heartbeat verdict is a snapshot taken before a SCAN that can run for
37
+ # a long time on a big keyspace, and a booting process can claim jobs a
38
+ # moment before its first beat lands. Draining on that snapshot alone
39
+ # reclaims a live worker's in-flight job — a double run. So a heartbeat-
40
+ # judged orphan is re-checked right before its drain: its list must have
41
+ # sat untouched for `grace` seconds (OBJECT IDLETIME; a live owner touches
42
+ # it on every claim and ACK), and its identity must still have no `info`
43
+ # hash. Our own incarnation skips both: kill(0) is not a snapshot.
44
+ #
33
45
  # Re-pushed jobs run through Wurk::Middleware::PoisonPill, which caps a
34
46
  # job at RECOVERY_THRESHOLD recoveries within 72h: past the cap the job
35
47
  # is killed into the dead set instead of re-queued, so a job that crashes
@@ -59,17 +71,31 @@ module Wurk
59
71
  # the fleet, since a global SCAN is far costlier than the scoped pass.
60
72
  FULL_INTERVAL = 3600
61
73
 
74
+ # Seconds a heartbeat-judged private list must have sat idle before it is
75
+ # drained. Far below the heartbeat TTL a dead owner's list already waits
76
+ # out, so it never delays a real reclaim; well above the gap between a
77
+ # booting process's first claim and its first beat.
78
+ DEFAULT_GRACE = 30
79
+
62
80
  LOCK_KEY = 'super_fetch:reaper'
63
81
  FULL_LOCK_KEY = 'super_fetch:reaper:full'
64
82
  SCAN_COUNT = 100
83
+
84
+ # The hourly sweep walks the whole keyspace, where almost nothing
85
+ # matches: COUNT is the work per SCAN call, so a bigger batch is the
86
+ # same server work in a tenth of the round trips — a million keys is
87
+ # 1,000 calls instead of 10,000. Still small enough that one call never
88
+ # holds Redis for more than a fraction of a millisecond.
89
+ FULL_SCAN_COUNT = 1000
65
90
  THREAD_NAME = 'wurk-reaper'
66
91
 
67
92
  attr_reader :interval
68
93
 
69
94
  def initialize(config, interval: DEFAULT_INTERVAL, lock_key: LOCK_KEY,
70
- full_interval: FULL_INTERVAL, full_lock_key: FULL_LOCK_KEY)
95
+ full_interval: FULL_INTERVAL, full_lock_key: FULL_LOCK_KEY, grace: DEFAULT_GRACE)
71
96
  @config = config
72
97
  @interval = interval
98
+ @grace = OrphanGrace.new(config, grace)
73
99
  @lock_key = lock_key
74
100
  @full_interval = full_interval
75
101
  @full_lock_key = full_lock_key
@@ -77,6 +103,7 @@ module Wurk
77
103
  @done = false
78
104
  @mutex = ::Mutex.new
79
105
  @sleeper = ::ConditionVariable.new
106
+ @unparseable = UnparseableKeys.new(config)
80
107
  end
81
108
 
82
109
  # Spawns the sweep loop. Idempotent. The loop waits one interval before
@@ -139,7 +166,7 @@ module Wurk
139
166
  owners = live_owners
140
167
  reclaimed = 0
141
168
  each_full_private_list do |key, public_q, host, pid, nonce|
142
- next if owner_alive?(host, pid, nonce, owners)
169
+ next unless orphaned?(key, host, pid, nonce, owners)
143
170
 
144
171
  reclaimed += drain(key, public_q)
145
172
  end
@@ -162,7 +189,7 @@ module Wurk
162
189
  def reclaim_queue(public_q, owners)
163
190
  reclaimed = 0
164
191
  each_private_list(public_q) do |key, host, pid, nonce|
165
- next if owner_alive?(host, pid, nonce, owners)
192
+ next unless orphaned?(key, host, pid, nonce, owners)
166
193
 
167
194
  reclaimed += drain(key, public_q)
168
195
  end
@@ -179,8 +206,8 @@ module Wurk
179
206
  c.call('SCAN', cursor, 'MATCH', "#{public_q}|*", 'COUNT', SCAN_COUNT)
180
207
  end
181
208
  keys.each do |key|
182
- host, pid, nonce = parse_owner(public_q, key)
183
- yield key, host, pid, nonce if pid
209
+ host, pid, nonce = PrivateListKey.parse_owner(public_q, key)
210
+ pid ? yield(key, host, pid, nonce) : @unparseable.report(key)
184
211
  end
185
212
  break if cursor == '0'
186
213
  end
@@ -188,83 +215,22 @@ module Wurk
188
215
 
189
216
  # Yields [private_list_key, public_q, host, pid, nonce] for every list in
190
217
  # the keyspace. MATCH `queue:*|*` matches only private lists (public queue
191
- # keys carry no `|`); parse_full_key drops anything that isn't a
218
+ # keys carry no `|`); PrivateListKey.parse_full drops anything that isn't a
192
219
  # well-formed `queue:<public>|<host>|<pid>|<nonce>|<idx>`.
193
220
  def each_full_private_list
194
221
  cursor = '0'
195
222
  loop do
196
223
  cursor, keys = redis(idempotent: true) do |c|
197
- c.call('SCAN', cursor, 'MATCH', "#{Keys::QUEUE_PREFIX}*|*", 'COUNT', SCAN_COUNT)
224
+ c.call('SCAN', cursor, 'MATCH', "#{Keys::QUEUE_PREFIX}*|*", 'COUNT', FULL_SCAN_COUNT)
198
225
  end
199
226
  keys.each do |key|
200
- parsed = parse_full_key(key)
201
- yield key, *parsed if parsed
227
+ parsed = PrivateListKey.parse_full(key)
228
+ parsed ? yield(key, *parsed) : @unparseable.report(key)
202
229
  end
203
230
  break if cursor == '0'
204
231
  end
205
232
  end
206
233
 
207
- # `queue:<public>|<host>|<pid>|<nonce>|<idx>` → [public_q, host, pid,
208
- # nonce], parsed from the right so a `|` inside the queue name is
209
- # tolerated. nil when the key isn't a well-formed private list.
210
- #
211
- # With no known prefix to split on, both owner shapes are tried in
212
- # preference order and the first one leaving a real public queue behind
213
- # wins. The narrow reading is what saves a pre-nonce key from an all-digit
214
- # host (`queue:q|123456789012|<pid>|<idx>` — a bare Docker hostname is 12
215
- # hex chars): read wide, its host segment eats the whole queue name.
216
- def parse_full_key(key)
217
- parts = key.split('|')
218
- owner_tails(parts).each do |host, pid, nonce, width|
219
- public_q = parts[0...-width].join('|')
220
- next unless public_q.start_with?(Keys::QUEUE_PREFIX) && public_q != Keys::QUEUE_PREFIX
221
-
222
- return [public_q, host, pid, nonce]
223
- end
224
- nil
225
- end
226
-
227
- # `<public_q>|<host>|<pid>|<nonce>|<idx>` → [host, pid, nonce] (pid as
228
- # Integer), or all-nil when the suffix isn't a well-formed owner tail.
229
- # Splitting the suffix off the known public-queue prefix tolerates a `|`
230
- # inside the queue name itself, and leaves the tail unambiguous: exactly
231
- # 4 segments for the current shape, exactly 3 for the pre-nonce one.
232
- def parse_owner(public_q, key)
233
- suffix = key.delete_prefix("#{public_q}|")
234
- return [nil, nil, nil] if suffix == key
235
-
236
- host, pid, nonce = owner_tails(suffix.split('|')).first
237
- [host, pid, nonce]
238
- end
239
-
240
- # Owner segments of a private-list key, taken from the right, as
241
- # [host, pid, nonce, segment_count] readings in preference order (empty
242
- # when nothing parses). The wide shape is preferred: an all-digit nonce is
243
- # rare but reachable, and reading such a key narrow would take the pid for
244
- # the host and the nonce for the pid — draining a live owner's list out
245
- # from under it.
246
- def owner_tails(parts)
247
- return [] unless parts.size >= 3 && integer?(parts[-1])
248
-
249
- [wide_tail(parts), narrow_tail(parts)].compact
250
- end
251
-
252
- # `<host>|<pid>|<nonce>|<idx>` — the shape every current process writes.
253
- def wide_tail(parts)
254
- [parts[-4], parts[-3].to_i, parts[-2], 4] if parts.size >= 4 && integer?(parts[-3])
255
- end
256
-
257
- # `<host>|<pid>|<idx>` — written before the nonce existed. Such a list can
258
- # still hold a pre-upgrade process's in-flight jobs across a rolling
259
- # upgrade, so it stays reclaimable even though nothing writes it anymore.
260
- def narrow_tail(parts)
261
- [parts[-3], parts[-2].to_i, nil, 3] if integer?(parts[-2])
262
- end
263
-
264
- def integer?(str)
265
- str.is_a?(String) && str.match?(/\A\d+\z/)
266
- end
267
-
268
234
  # `Process.kill(0, pid)` answers "does this pid exist *in my PID
269
235
  # namespace*", which is the question we're actually asking only when the
270
236
  # key was written from that same namespace. A shared hostname does not
@@ -281,11 +247,26 @@ module Wurk
281
247
  # Every other owner goes through the namespace-blind heartbeat — alive iff
282
248
  # its identity is a live `processes` member. A pre-nonce key can only be
283
249
  # matched on the `<host>:<pid>` prefix of that identity.
284
- def owner_alive?(host, pid, nonce, owners)
285
- return local_pid_alive?(pid) if nonce == process_nonce && host == hostname
286
- return owners.include?("#{host}:#{pid}:#{nonce}") if nonce
250
+ def orphaned?(key, host, pid, nonce, owners)
251
+ return !local_pid_alive?(pid) if nonce == process_nonce && host == hostname
252
+ return false if owners.include?(owner_key(host, pid, nonce))
253
+
254
+ settled_orphan?(key, host, pid, nonce)
255
+ end
256
+
257
+ def owner_key(host, pid, nonce)
258
+ nonce ? "#{host}:#{pid}:#{nonce}" : "#{host}:#{pid}"
259
+ end
260
+
261
+ # The re-check right before a heartbeat-judged drain (see the class doc):
262
+ # the `owners` snapshot may predate the owner's first beat, or the whole
263
+ # SCAN. Only reached for lists the snapshot already called orphaned, so
264
+ # its round trips are paid per orphan, never per live list.
265
+ def settled_orphan?(key, host, pid, nonce)
266
+ return false if @grace.recent?(key)
267
+ return !live_owners.include?(owner_key(host, pid, nil)) unless nonce
287
268
 
288
- owners.include?("#{host}:#{pid}")
269
+ redis(idempotent: true) { |c| c.call('HGET', owner_key(host, pid, nonce), 'info') }.nil?
289
270
  end
290
271
 
291
272
  def local_pid_alive?(pid)
@@ -7,6 +7,7 @@ require_relative '../keys'
7
7
  require_relative '../lua'
8
8
  require_relative '../fetcher'
9
9
  require_relative 'capped'
10
+ require_relative 'many_queues'
10
11
 
11
12
  module Wurk
12
13
  class Fetcher
@@ -23,10 +24,13 @@ module Wurk
23
24
  # the paths that must send a held ACK before they stop fetching, and
24
25
  # docs/idea/parity-divergences.md for the window that widens.
25
26
  #
26
- # Priority handling: iterate queues_cmd in order with non-blocking
27
- # LMOVE, then fall back to a blocking BLMOVE on the first queue so an
28
- # empty poll doesn't spin Redis. BLMOVE has no multi-key form, so
29
- # blocking on a single queue is the best Redis gives us. The block
27
+ # Priority handling: walk queues_cmd in order with a non-blocking claim —
28
+ # an LMOVE on the first queue, then one fetch_first script call over all
29
+ # the others (ManyQueues) — then fall back to a blocking BLMOVE
30
+ # on the first queue so an empty poll doesn't spin Redis. BLMOVE has no
31
+ # multi-key form, so blocking on a single queue is the best Redis gives us:
32
+ # a job landing on a later queue while every thread is parked waits for
33
+ # the next pass, at most one block timeout. The block
30
34
  # timeout defaults to TIMEOUT (2s) and is overridable per the Pro
31
35
  # super_fetch §3.3 `config.fetch_poll_interval` knob.
32
36
  #
@@ -40,6 +44,7 @@ module Wurk
40
44
  # that caps nothing, which is a stronger guarantee that the unconfigured
41
45
  # path is unchanged than a second loop kept in step by hand.
42
46
  prepend Capped
47
+ include ManyQueues
43
48
 
44
49
  # Default BLMOVE block timeout; overridable via config.fetch_poll_interval.
45
50
  TIMEOUT = 2
@@ -72,7 +77,7 @@ module Wurk
72
77
  # namespaces are in play: a restarted container reuses both, so the
73
78
  # reaper's `kill(0)` liveness check would read a dead owner's list as
74
79
  # live (jobs stranded) or a live owner's as dead (job run twice). Keys
75
- # written before the nonce existed stay reclaimable — Reaper#parse_owner
80
+ # written before the nonce existed stay reclaimable — PrivateListKey
76
81
  # accepts both shapes.
77
82
  def self.private_queue_name(public_queue, index = 0)
78
83
  host = ENV['DYNO'] || Socket.gethostname
@@ -112,6 +117,7 @@ module Wurk
112
117
  @queue_keys_pid = ::Process.pid
113
118
  @prefixed_queues = nil
114
119
  @prefixed_source = nil
120
+ @claim_keys = nil
115
121
  end
116
122
 
117
123
  # Take custody of a finished job's LREM instead of sending it now. One
@@ -246,20 +252,20 @@ module Wurk
246
252
  config.config.handle_exception(ex, ctx)
247
253
  end
248
254
 
249
- # Non-blocking pass over the fetchable queues. The pending ACK rides the
250
- # first LMOVE and only the first: the walk is one fetch, so a second copy
251
- # of the same LREM would be a wasted command on every queue we find
252
- # empty. By the time we fall through to BLMOVE this thread is holding
253
- # nothing — which is the point, since a blocking call can't join a
254
- # pipeline and would strand the ACK for a whole poll interval.
255
+ # Non-blocking pass over the fetchable queues, then the blocking
256
+ # fall-through. The pending ACK rides the first LMOVE and only the first.
257
+ # By the time we fall through to BLMOVE this thread is holding nothing —
258
+ # which is the point, since a blocking call can't join a pipeline and
259
+ # would strand the ACK for a whole poll interval.
260
+ #
261
+ # The first queue is always a bare LMOVE, so the busy-first-queue steady
262
+ # state (and the one-queue capsule `rake bench` gates) costs exactly what
263
+ # it did. Only a miss there pays for the rest — see ManyQueues.
255
264
  def walk(queues)
256
265
  ack = take_pending_ack
257
- queues.each do |public_q|
258
- uow = lmove(public_q, ack)
259
- ack = nil
260
- return uow if uow
261
- end
262
- blmove(queues.first)
266
+ uow = lmove(queues.first, ack)
267
+ uow ||= claim_rest(queues) if queues.size > 1
268
+ uow || blmove(queues.first)
263
269
  end
264
270
 
265
271
  # Exactly one, even when a failed flush left this thread holding two: the