wurk 1.4.0 → 1.6.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -3
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/app/controllers/wurk/api/serializers.rb +51 -1
  5. data/app/controllers/wurk/api_controller.rb +42 -6
  6. data/app/controllers/wurk/dashboard_controller.rb +66 -10
  7. data/config/routes.rb +30 -3
  8. data/exe/wurk +6 -2
  9. data/lib/generators/wurk/install/install_generator.rb +3 -3
  10. data/lib/sidekiq/job_retry.rb +4 -0
  11. data/lib/sidekiq/manager.rb +4 -0
  12. data/lib/sidekiq/processor.rb +4 -0
  13. data/lib/wurk/api/app.rb +186 -0
  14. data/lib/wurk/api/auth.rb +167 -0
  15. data/lib/wurk/api/flows.rb +66 -0
  16. data/lib/wurk/api/idempotency.rb +168 -0
  17. data/lib/wurk/api/jobs.rb +180 -0
  18. data/lib/wurk/api/page.rb +98 -0
  19. data/lib/wurk/api/problem.rb +121 -0
  20. data/lib/wurk/api/queues.rb +126 -0
  21. data/lib/wurk/api/read_only.rb +68 -0
  22. data/lib/wurk/api/request.rb +46 -0
  23. data/lib/wurk/api/response.rb +28 -0
  24. data/lib/wurk/api/roll_up.rb +126 -0
  25. data/lib/wurk/api/router.rb +88 -0
  26. data/lib/wurk/api/serializers.rb +232 -0
  27. data/lib/wurk/api/swarm.rb +297 -0
  28. data/lib/wurk/api/throttle.rb +109 -0
  29. data/lib/wurk/api/validation.rb +306 -0
  30. data/lib/wurk/api.rb +96 -0
  31. data/lib/wurk/batch/server_middleware.rb +2 -2
  32. data/lib/wurk/batch.rb +12 -3
  33. data/lib/wurk/capsule.rb +48 -13
  34. data/lib/wurk/cli.rb +99 -4
  35. data/lib/wurk/client/buffered.rb +8 -1
  36. data/lib/wurk/client.rb +144 -35
  37. data/lib/wurk/collapse.rb +383 -0
  38. data/lib/wurk/compat.rb +19 -0
  39. data/lib/wurk/component.rb +48 -4
  40. data/lib/wurk/configuration.rb +348 -10
  41. data/lib/wurk/context.rb +1 -1
  42. data/lib/wurk/debounce.rb +125 -0
  43. data/lib/wurk/encryption.rb +6 -1
  44. data/lib/wurk/engine.rb +41 -1
  45. data/lib/wurk/errors.rb +15 -0
  46. data/lib/wurk/fetcher/capped.rb +218 -0
  47. data/lib/wurk/fetcher/reaper.rb +12 -2
  48. data/lib/wurk/fetcher/reliable.rb +307 -90
  49. data/lib/wurk/fetcher/unit_of_work.rb +100 -0
  50. data/lib/wurk/fetcher.rb +6 -0
  51. data/lib/wurk/flow/builder.rb +271 -0
  52. data/lib/wurk/flow/chain.rb +42 -0
  53. data/lib/wurk/flow/completion.rb +114 -0
  54. data/lib/wurk/flow/creation.rb +254 -0
  55. data/lib/wurk/flow/node.rb +124 -0
  56. data/lib/wurk/flow/status.rb +206 -0
  57. data/lib/wurk/flow.rb +255 -0
  58. data/lib/wurk/flow_set.rb +53 -0
  59. data/lib/wurk/health.rb +8 -7
  60. data/lib/wurk/heartbeat.rb +23 -5
  61. data/lib/wurk/job/options.rb +11 -1
  62. data/lib/wurk/job.rb +19 -0
  63. data/lib/wurk/job_logger.rb +16 -7
  64. data/lib/wurk/job_retry.rb +6 -1
  65. data/lib/wurk/job_set.rb +3 -2
  66. data/lib/wurk/job_util.rb +153 -32
  67. data/lib/wurk/keys.rb +125 -0
  68. data/lib/wurk/launcher.rb +117 -73
  69. data/lib/wurk/leader.rb +37 -7
  70. data/lib/wurk/limiter/bucket.rb +1 -1
  71. data/lib/wurk/limiter/points.rb +1 -1
  72. data/lib/wurk/logger.rb +1 -1
  73. data/lib/wurk/lua/debounce.lua +75 -0
  74. data/lib/wurk/lua/fetch_slot.lua +83 -0
  75. data/lib/wurk/lua/flow_abandon.lua +65 -0
  76. data/lib/wurk/lua/flow_advance.lua +177 -0
  77. data/lib/wurk/lua/flow_create.lua +141 -0
  78. data/lib/wurk/lua/flow_fail.lua +52 -0
  79. data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
  80. data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
  81. data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
  82. data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
  83. data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
  84. data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
  85. data/lib/wurk/lua/limiter_points_refund.lua +18 -0
  86. data/lib/wurk/lua/limiter_register.lua +27 -0
  87. data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
  88. data/lib/wurk/lua/limiter_window_status.lua +22 -0
  89. data/lib/wurk/lua/loader.rb +32 -3
  90. data/lib/wurk/lua/queue_slot.lua +83 -0
  91. data/lib/wurk/lua/refresh_slots.lua +38 -0
  92. data/lib/wurk/lua/status_write.lua +29 -0
  93. data/lib/wurk/lua/throttle_slot.lua +71 -0
  94. data/lib/wurk/lua.rb +3 -3
  95. data/lib/wurk/manager.rb +12 -0
  96. data/lib/wurk/metrics/accumulator.rb +95 -0
  97. data/lib/wurk/metrics/flusher.rb +70 -0
  98. data/lib/wurk/metrics/history.rb +132 -31
  99. data/lib/wurk/metrics/query.rb +1 -1
  100. data/lib/wurk/metrics/statsd.rb +46 -18
  101. data/lib/wurk/middleware/chain.rb +31 -14
  102. data/lib/wurk/middleware/expiry.rb +71 -10
  103. data/lib/wurk/middleware/poison_pill.rb +23 -2
  104. data/lib/wurk/middleware/status.rb +274 -0
  105. data/lib/wurk/middleware/timeout.rb +137 -0
  106. data/lib/wurk/pool_checkout.rb +10 -0
  107. data/lib/wurk/processor.rb +141 -29
  108. data/lib/wurk/profiler.rb +6 -4
  109. data/lib/wurk/queue.rb +8 -0
  110. data/lib/wurk/queue_slot.rb +285 -0
  111. data/lib/wurk/rails.rb +3 -3
  112. data/lib/wurk/redis_client_adapter.rb +1 -1
  113. data/lib/wurk/redis_pool.rb +2 -1
  114. data/lib/wurk/shutdown_gate.rb +79 -0
  115. data/lib/wurk/stats.rb +5 -1
  116. data/lib/wurk/status/progress.rb +103 -0
  117. data/lib/wurk/status/record.rb +81 -0
  118. data/lib/wurk/status.rb +155 -0
  119. data/lib/wurk/swarm/child_boot.rb +24 -4
  120. data/lib/wurk/swarm.rb +88 -14
  121. data/lib/wurk/telemetry/client_middleware.rb +59 -0
  122. data/lib/wurk/telemetry/server_middleware.rb +152 -0
  123. data/lib/wurk/telemetry.rb +137 -0
  124. data/lib/wurk/throttle.rb +142 -0
  125. data/lib/wurk/unique.rb +10 -2
  126. data/lib/wurk/version.rb +1 -1
  127. data/lib/wurk/watchdog.rb +188 -0
  128. data/lib/wurk/web/config.rb +88 -17
  129. data/lib/wurk/web/extension.rb +2 -2
  130. data/lib/wurk/web/locale_negotiator.rb +67 -0
  131. data/lib/wurk/web.rb +1 -0
  132. data/lib/wurk/worker.rb +39 -4
  133. data/lib/wurk.rb +42 -9
  134. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
  135. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
  136. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
  137. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
  138. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
  139. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
  140. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
  141. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
  142. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
  143. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
  144. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
  145. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
  146. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
  147. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
  148. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
  149. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
  150. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
  151. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
  152. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
  153. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
  154. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
  155. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
  156. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
  157. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
  158. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
  159. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
  160. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
  161. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
  162. data/vendor/assets/dashboard/index.html +42 -11
  163. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  164. metadata +105 -34
  165. data/vendor/assets/dashboard/assets/ArgsValue-D74zX0MI.js +0 -1
  166. data/vendor/assets/dashboard/assets/BatchDetail-OmC5NPgw.js +0 -1
  167. data/vendor/assets/dashboard/assets/Batches-CIpai7St.js +0 -1
  168. data/vendor/assets/dashboard/assets/Busy-A_kwSR6Q.js +0 -1
  169. data/vendor/assets/dashboard/assets/Cron-BG7HTqlp.js +0 -1
  170. data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
  171. data/vendor/assets/dashboard/assets/Dead-8J21jMyK.js +0 -1
  172. data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +0 -1
  173. data/vendor/assets/dashboard/assets/FilterBox-Fh_Ae7UW.js +0 -1
  174. data/vendor/assets/dashboard/assets/JobDetailModal-Ceng0PMB.js +0 -2
  175. data/vendor/assets/dashboard/assets/Limiters-CruDWvNZ.js +0 -1
  176. data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
  177. data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +0 -1
  178. data/vendor/assets/dashboard/assets/PageHeader-C44KNMGm.js +0 -1
  179. data/vendor/assets/dashboard/assets/Profiles-xEVTyS2N.js +0 -1
  180. data/vendor/assets/dashboard/assets/Queues-D86FYohJ.js +0 -1
  181. data/vendor/assets/dashboard/assets/Retries-Bz1O1D-i.js +0 -1
  182. data/vendor/assets/dashboard/assets/Scheduled-B6h2akTu.js +0 -1
  183. data/vendor/assets/dashboard/assets/Search-OOu22e5s.js +0 -1
  184. data/vendor/assets/dashboard/assets/Skeleton-DzR7XNxz.js +0 -1
  185. data/vendor/assets/dashboard/assets/charts-BVHHGof7.js +0 -1
  186. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
  187. data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +0 -141
  188. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-dVPGEWzn.js +0 -1
  189. data/vendor/assets/dashboard/assets/useSort-BeYbztkN.js +0 -1
  190. data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
data/lib/wurk/cli.rb CHANGED
@@ -24,6 +24,27 @@ module Wurk
24
24
  # format and Lua scripts rely on commands introduced in Redis 7.
25
25
  MIN_REDIS_VERSION = '7.0.0'
26
26
 
27
+ # Subcommands. Bare `wurk` runs the worker — the historical shape, and the
28
+ # only one the swarm binaries accept — so a subcommand is never anything
29
+ # but the first argument, and every argument after it is that command's.
30
+ COMMANDS = %w[api].freeze
31
+
32
+ # `wurk api` defaults. 0.0.0.0 because the point of the machine API is for
33
+ # another service to reach it; it is bearer-gated, and answers 404 to
34
+ # everything until a token exists. `--bind 127.0.0.1` narrows it.
35
+ DEFAULT_API_BIND = '0.0.0.0'
36
+ DEFAULT_API_PORT = 7433
37
+
38
+ # Serving the API with nothing registered would bind a port and 404 every
39
+ # request — the surface is off until a token exists, so say so at boot
40
+ # rather than leaving an operator to diagnose it over HTTP.
41
+ NO_API_TOKEN_MESSAGE = <<~MSG
42
+ No API token is registered, so every request would answer 404.
43
+ Register one where the app loaded by -r configures wurk:
44
+
45
+ Wurk.configuration.api_token(ENV.fetch('WURK_API_TOKEN'), scopes: %i[enqueue read])
46
+ MSG
47
+
27
48
  # Thread-backtrace dumper used by both TTIN and INFO. Same body — INFO is
28
49
  # the modern name, TTIN is kept for parity with older Sidekiq users.
29
50
  BACKTRACE_DUMPER = lambda do |cli|
@@ -68,8 +89,20 @@ module Wurk
68
89
  ['-C', '--config PATH', :config_file, 'path to YAML config file']
69
90
  ].freeze
70
91
 
92
+ # Same table shape, offered only when `api` is the subcommand: none of it
93
+ # means anything to a worker process, and `wurk --help` stays the worker's.
94
+ API_OPTION_FLAGS = [
95
+ ['-b', '--bind ADDRESS', :api_bind, "Address to bind (default #{DEFAULT_API_BIND})"],
96
+ ['-p', '--port PORT', :api_port, "Port to listen on (default #{DEFAULT_API_PORT})", :to_i],
97
+ ['-s', '--server NAME', :api_server, 'Rack handler to serve with (default: the first installed)']
98
+ ].freeze
99
+
71
100
  attr_accessor :launcher, :environment, :config
72
101
 
102
+ # The subcommand `parse` pulled off argv, or nil for the worker runner.
103
+ # The binary picks the mode from it — `exe/wurk` runs the API for `api`.
104
+ attr_reader :command
105
+
73
106
  def self.instance
74
107
  @instance ||= new
75
108
  end
@@ -84,12 +117,14 @@ module Wurk
84
117
  @launcher = nil
85
118
  @environment = nil
86
119
  @parser = nil
120
+ @command = nil
87
121
  end
88
122
 
89
123
  # `parse` is split from `run` so tests can drive option parsing without
90
124
  # touching Redis or booting the host app.
91
125
  def parse(args = ARGV.dup)
92
126
  @config ||= Wurk.default_configuration
127
+ @command = extract_command!(args)
93
128
  setup_options(args)
94
129
  initialize_logger
95
130
  validate!
@@ -137,6 +172,8 @@ module Wurk
137
172
  # `SIDEKIQ_PRELOAD_APP` whole-app eager-load) and boots `Process.warmup`
138
173
  # before the fork so children share warmed pages (copy-on-write). Spec §7.
139
174
  def run_swarm(boot_app: true, warmup: true)
175
+ raise ArgumentError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
176
+
140
177
  # Server mode before the app loads — see #run. The flag rides through the
141
178
  # fork into every child (the config object is copied), so configure_server
142
179
  # blocks registered in the parent take effect in the workers.
@@ -163,6 +200,32 @@ module Wurk
163
200
  end
164
201
  end
165
202
 
203
+ # `wurk api` — serve the machine-facing HTTP API and nothing else: no
204
+ # fetcher, no heartbeat, no job ever runs here. This is mount mode 3, the
205
+ # standalone one: the same Rack app the engine nests and a host can mount
206
+ # itself, served without Rails.
207
+ #
208
+ # Server mode is entered for the reason #run does it — a host that
209
+ # registers its token inside a `configure_server` block would otherwise
210
+ # boot an API with no credential and 404 every request. Nothing starts as a
211
+ # side effect: no lifecycle event fires and no Launcher is ever built.
212
+ #
213
+ # `handler:` is a test seam; production resolves one from the installed
214
+ # gems.
215
+ def run_api(boot_app: true, handler: nil)
216
+ enter_server_mode
217
+ boot_application if boot_app
218
+ validate_redis!
219
+ # Load the app here, not on the first request: a broken load should fail
220
+ # the boot an operator is watching, not the first client to call.
221
+ require_relative 'api/app'
222
+ raise ArgumentError, NO_API_TOKEN_MESSAGE unless @config.api_enabled?
223
+
224
+ handler ||= api_handler(@config[:api_server])
225
+ logger.info { "Wurk API listening on http://#{api_bind}:#{api_port}#{Wurk::API::VERSION_PREFIX}" }
226
+ handler.run(Wurk::API, Host: api_bind, Port: api_port)
227
+ end
228
+
166
229
  def handle_signal(sig)
167
230
  logger.debug { "Got #{sig} signal" }
168
231
  handler = SIGNAL_HANDLERS[sig]
@@ -188,6 +251,29 @@ module Wurk
188
251
  ::Process.warmup
189
252
  end
190
253
 
254
+ def api_bind = @config[:api_bind] || DEFAULT_API_BIND
255
+ def api_port = @config[:api_port] || DEFAULT_API_PORT
256
+
257
+ # Rack 3 moved the server handlers out to the `rackup` gem; rack 2 still
258
+ # ships them itself. Neither is a wurk dependency — the web server is the
259
+ # host's choice here exactly as it is for the app this sits next to — so
260
+ # resolve one at run time and name the fix when there is none.
261
+ def api_handler(name)
262
+ registry = handler_registry
263
+ name ? registry.get(name) : registry.default
264
+ rescue ::LoadError, ::NameError, ::ArgumentError => e
265
+ raise ArgumentError, "wurk api could not start a web server (#{e.message}). Add one to your Gemfile " \
266
+ '— puma, falcon, or rackup + webrick — or name an installed one with --server.'
267
+ end
268
+
269
+ def handler_registry
270
+ require 'rackup/handler'
271
+ ::Rackup::Handler
272
+ rescue ::LoadError
273
+ require 'rack/handler'
274
+ ::Rack::Handler
275
+ end
276
+
191
277
  def launch(self_read)
192
278
  @launcher = Wurk::Launcher.new(@config)
193
279
  begin
@@ -270,6 +356,14 @@ module Wurk
270
356
 
271
357
  # --- options + config-file --------------------------------------------
272
358
 
359
+ # Pulled off before OptionParser sees argv, so the banner and the flag
360
+ # table can depend on which command was named. A bare word OptionParser
361
+ # doesn't recognize would otherwise survive `parse!` and be silently
362
+ # ignored, which is how `wurk api` would look like it worked.
363
+ def extract_command!(args)
364
+ COMMANDS.include?(args.first) ? args.shift : nil
365
+ end
366
+
273
367
  def setup_options(args)
274
368
  opts = parse_options(args)
275
369
  set_environment(opts[:environment])
@@ -351,14 +445,15 @@ module Wurk
351
445
 
352
446
  def option_parser(opts)
353
447
  ::OptionParser.new do |o|
354
- o.banner = 'wurk [options]'
355
- define_value_flags(o, opts)
448
+ o.banner = @command ? "wurk #{@command} [options]" : 'wurk [options]'
449
+ define_value_flags(o, opts, OPTION_FLAGS)
450
+ define_value_flags(o, opts, API_OPTION_FLAGS) if @command == 'api'
356
451
  define_meta_flags(o, opts)
357
452
  end
358
453
  end
359
454
 
360
- def define_value_flags(parser, opts)
361
- OPTION_FLAGS.each do |short, long, key, desc, transform|
455
+ def define_value_flags(parser, opts, flags)
456
+ flags.each do |short, long, key, desc, transform|
362
457
  parser.on(short, long, desc) { |arg| assign_flag(opts, key, arg, transform) }
363
458
  end
364
459
  end
@@ -119,6 +119,13 @@ module Wurk
119
119
  @overflow_mode = nil
120
120
  @buffer_client_factory = nil
121
121
  end
122
+ # Stop before dropping: an unstopped drainer thread survives with
123
+ # its factory nil'd out from under it and ticks forever against
124
+ # nothing, leaking the thread and everything its closure retains.
125
+ install_mutex.synchronize do
126
+ @drainer&.stop
127
+ @drainer = nil
128
+ end
122
129
  end
123
130
 
124
131
  # Fork hook, called from the `Process._fork` prepend below and from
@@ -524,7 +531,7 @@ module Wurk
524
531
  # next push — this is a Wurk extension for issue #19's "Background
525
532
  # drain thread flushes on reconnect" so producer-stopped-mid-outage
526
533
  # buffers don't sit idle until next push.
527
- def reliable_push_drainer(interval: Buffered::Drainer::DEFAULT_INTERVAL)
534
+ def reliable_push_drainer(interval: Buffered::Drainer::DEFAULT_INTERVAL) # rubocop:disable Naming/PredicateMethod
528
535
  Buffered.install!
529
536
  Buffered.start_drainer!(interval: interval)
530
537
  true
data/lib/wurk/client.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'iterable_job'
4
4
  require_relative 'job_util'
5
5
  require_relative 'lua'
6
+ require_relative 'status'
6
7
 
7
8
  module Wurk
8
9
  # Enqueue interface. Pipelined LPUSH / ZADD writes against the canonical
@@ -17,7 +18,7 @@ module Wurk
17
18
  # Wurk::Client.new.push_bulk("class" => "MyJob", "args" => [[1], [2], [3]])
18
19
  #
19
20
  # Spec: docs/target/sidekiq-free.md §7.
20
- class Client
21
+ class Client # rubocop:disable Metrics/ClassLength
21
22
  include JobUtil
22
23
 
23
24
  # Sidekiq mirrors these exactly. Tests against the upstream parity suite
@@ -26,6 +27,20 @@ module Wurk
26
27
  SCHEDULED_BATCH_SIZE = 100
27
28
  SPREAD_INTERVAL_FLOOR = 5
28
29
 
30
+ # Batched (`bid`) payloads per EVALSHA pipeline. Ours, not Sidekiq's — it
31
+ # has no batches. Sized to DEFAULT_BATCH_SIZE so no existing caller's
32
+ # round-trip count moves: `push_bulk` already hands #raw_push at most that
33
+ # many payloads, so its batched pipeline stays exactly one round trip.
34
+ #
35
+ # The cap is for the one path that isn't pre-sliced: `autoflush = true`
36
+ # buffers a whole `Batch#jobs` block, so #flush_batched can be handed an
37
+ # unbounded payload set. Unsliced that is one pipeline holding every
38
+ # command and every reply in memory at once, and — Lua being atomic and
39
+ # single-threaded — one uninterrupted server-side sweep that blocks every
40
+ # other client for its duration. Same reasoning as the LIMIT on
41
+ # RELIABLE_SCHEDULE_PROMOTE.
42
+ BATCH_PIPELINE_SLICE = 1_000
43
+
29
44
  # Thread-local slot holding the payloads of the current push whose Redis
30
45
  # write is confirmed applied. {Client::Buffered} subtracts them from the set
31
46
  # it re-buffers when a *later* phase of the same push loses the connection,
@@ -72,9 +87,10 @@ module Wurk
72
87
  validate_bulk_shape!(items, args)
73
88
  return [] if args.empty?
74
89
 
90
+ base = bulk_base(items)
91
+ reject_bulk_collapse!(base)
75
92
  at_values = expand_at(items, args.size)
76
93
  batch_sz = items['batch_size'] || items[:batch_size] || (at_values ? SCHEDULED_BATCH_SIZE : DEFAULT_BATCH_SIZE)
77
- base = bulk_base(items)
78
94
  flush_bulk(args, at_values, base, batch_sz)
79
95
  end
80
96
 
@@ -138,7 +154,21 @@ module Wurk
138
154
 
139
155
  private
140
156
 
157
+ # #push and #push_bulk verify at different points and both match Sidekiq
158
+ # exactly: push walks the payload the chain handed back (sidekiq
159
+ # client.rb:101 — normalize → middleware → verify → raw_push), bulk walks it
160
+ # inside the innermost block (sidekiq client.rb:165). Push used to do both,
161
+ # and since `strict_args_mode` defaults to :raise the second full recursive
162
+ # args walk was never skipped.
163
+ #
164
+ # Bulk keeps its walk inside the block on purpose: a client middleware that
165
+ # halts the job there short-circuits the walk, and hoisting it out would
166
+ # raise on args that middleware was about to drop.
141
167
  def invoke_chain(normed)
168
+ @chain.invoke(normed['class'], normed, normed['queue'], pool) { normed }
169
+ end
170
+
171
+ def invoke_chain_verified(normed)
142
172
  @chain.invoke(normed['class'], normed, normed['queue'], pool) do
143
173
  verify_json(normed)
144
174
  normed
@@ -170,6 +200,19 @@ module Wurk
170
200
  base
171
201
  end
172
202
 
203
+ # Up front, before the first slice is written — a policy discovered halfway
204
+ # through would leave the slices already flushed in Redis. Two lookups and no
205
+ # allocation on the untouched path.
206
+ #
207
+ # `class_defaults_for` is the same resolution #normalize_item does per item,
208
+ # so this sees a policy in exactly the cases the middleware would: a bulk
209
+ # pushed by class *name* never merges class options at all, and so never
210
+ # carries one. Spelled out in {Wurk::Collapse.reject_bulk!}.
211
+ def reject_bulk_collapse!(base)
212
+ key = Wurk::Collapse::OPTION
213
+ Wurk::Collapse.reject_bulk!(base['class'], base[key] || class_defaults_for(base['class'])[key])
214
+ end
215
+
173
216
  def flush_bulk(args, at_values, base, batch_size)
174
217
  jids = []
175
218
  args.each_slice(batch_size).with_index do |slice, slice_index|
@@ -191,7 +234,7 @@ module Wurk
191
234
  item = base.merge('args' => job_args)
192
235
  item['at'] = ats[idx] if ats
193
236
  normed = normalize_item(item)
194
- invoke_chain(normed)
237
+ invoke_chain_verified(normed)
195
238
  end
196
239
  end
197
240
 
@@ -296,7 +339,7 @@ module Wurk
296
339
 
297
340
  def push_scheduled(conn, payloads)
298
341
  args = payloads.flat_map do |hash|
299
- [hash['at'].to_s, Wurk.dump_json(hash.except('enqueued_at', 'at'))]
342
+ [hash['at'].to_s, JobUtil.scheduled_member(hash)]
300
343
  end
301
344
  conn.call('ZADD', 'schedule', *args)
302
345
  end
@@ -314,6 +357,27 @@ module Wurk
314
357
  push_batched_pipelined(conn, batched, now) unless batched.empty?
315
358
  end
316
359
 
360
+ # One pipeline per BATCH_PIPELINE_SLICE payloads, each marked delivered the
361
+ # moment its reply is in — same contract as push_plain_group, and for the
362
+ # same reason: a slice that Redis already accepted must stay out of the
363
+ # reliable_push ledger, or a later slice's failure would report it as
364
+ # undelivered.
365
+ def push_batched_pipelined(conn, batched, now)
366
+ batched.each_slice(BATCH_PIPELINE_SLICE) do |slice|
367
+ eval_batched_slice(conn) { |pipe, eval_method| push_batched(pipe, slice, now, eval_method: eval_method) }
368
+ mark_delivered(slice)
369
+ end
370
+ end
371
+
372
+ # Same slicing and NOSCRIPT recovery as push_batched_pipelined, for the
373
+ # scheduled batched path (BATCH_SCHEDULE instead of BATCH_PUSH).
374
+ def push_batched_scheduled_pipelined(conn, batched)
375
+ batched.each_slice(BATCH_PIPELINE_SLICE) do |slice|
376
+ eval_batched_slice(conn) { |pipe, eval_method| push_batched_scheduled(pipe, slice, eval_method: eval_method) }
377
+ mark_delivered(slice)
378
+ end
379
+ end
380
+
317
381
  # Outside of test boots and `SCRIPT FLUSH` the rescue branch is dead
318
382
  # code; the eager `script_load_all` after fork keeps the script cache
319
383
  # hot for the life of the connection. The retry uses EVAL (source-embedded)
@@ -321,24 +385,18 @@ module Wurk
321
385
  # NOSCRIPT a second time under heavy CI load (WorkerTest 3.4/7.2 flake).
322
386
  # `script_load_all` still primes the cache so the *next* pipeline returns
323
387
  # to the EVALSHA fast path.
324
- def push_batched_pipelined(conn, batched, now)
325
- conn.pipelined { |pipe| push_batched(pipe, batched, now) }
326
- rescue RedisClient::CommandError => e
327
- raise unless e.message.to_s.start_with?('NOSCRIPT')
328
-
329
- Wurk::Lua::Loader.script_load_all(conn)
330
- conn.pipelined { |pipe| push_batched(pipe, batched, now, eval_method: :eval_with_source) }
331
- end
332
-
333
- # Same NOSCRIPT-recovery shape as push_batched_pipelined, for the scheduled
334
- # batched path (BATCH_SCHEDULE instead of BATCH_PUSH).
335
- def push_batched_scheduled_pipelined(conn, batched)
336
- conn.pipelined { |pipe| push_batched_scheduled(pipe, batched) }
388
+ #
389
+ # Replaying the slice is safe precisely because every command in it is the
390
+ # same script: a flushed cache NOSCRIPTs all of them and applies none.
391
+ # Recovery is per slice, so the slices already acknowledged above are never
392
+ # re-sent. Mirrors Fetcher::Reliable#requeue_pipelined.
393
+ def eval_batched_slice(conn)
394
+ conn.pipelined { |pipe| yield(pipe, :eval_cached) }
337
395
  rescue RedisClient::CommandError => e
338
396
  raise unless e.message.to_s.start_with?('NOSCRIPT')
339
397
 
340
398
  Wurk::Lua::Loader.script_load_all(conn)
341
- conn.pipelined { |pipe| push_batched_scheduled(pipe, batched, eval_method: :eval_with_source) }
399
+ conn.pipelined { |pipe| yield(pipe, :eval_with_source) }
342
400
  end
343
401
 
344
402
  # One pipeline per queue, marked delivered the moment its reply is in.
@@ -355,27 +413,67 @@ module Wurk
355
413
  # Cost is a round trip per distinct queue. The single-queue push — every
356
414
  # `perform_async`, every same-class `push_bulk` — still writes exactly the
357
415
  # one SADD + LPUSH pipeline it did before.
416
+ #
417
+ # `uniform_queue` short-circuits the common case (one job, or many jobs
418
+ # all destined for the same queue) without paying for the `group_by`
419
+ # Hash + per-group Array allocations; only a genuinely mixed-queue batch
420
+ # falls through to grouping.
358
421
  def push_plain(conn, payloads, now)
359
- payloads.group_by { |j| j['queue'] }.each do |queue, jobs|
360
- serialized = jobs.map do |j|
361
- j['enqueued_at'] = now
362
- Wurk.dump_json(j)
363
- end
364
- conn.pipelined do |pipe|
365
- pipe.call('SADD', 'queues', queue)
366
- pipe.call('LPUSH', "queue:#{queue}", *serialized)
367
- end
368
- mark_delivered(jobs)
422
+ queue = uniform_queue(payloads)
423
+ return push_plain_group(conn, queue, payloads, now) if queue
424
+
425
+ payloads.group_by { |j| j['queue'] }.each { |q, jobs| push_plain_group(conn, q, jobs, now) }
426
+ end
427
+
428
+ def uniform_queue(payloads)
429
+ first = payloads[0]['queue']
430
+ return first if payloads.size == 1
431
+
432
+ first if payloads.all? { |j| j['queue'] == first }
433
+ end
434
+
435
+ def push_plain_group(conn, queue, jobs, now)
436
+ serialized = jobs.map do |j|
437
+ j['enqueued_at'] = now
438
+ Wurk.dump_json(j)
439
+ end
440
+ conn.pipelined do |pipe|
441
+ pipe.call('SADD', 'queues', queue)
442
+ pipe.call('LPUSH', "queue:#{queue}", *serialized)
443
+ track_enqueued(pipe, jobs, now)
444
+ end
445
+ mark_delivered(jobs)
446
+ end
447
+
448
+ # Ride the pipeline that is already open for the queue write, so a tracked
449
+ # push costs the same round trip an untracked one does. An app that never
450
+ # opts in pays one Hash lookup per payload here and appends nothing: the
451
+ # plain push stays exactly the two commands (SADD + LPUSH) it has always
452
+ # been, which is what `rake bench:command_count_tracked_off` asserts.
453
+ #
454
+ # The TTL is resolved once per group, and only when something is actually
455
+ # tracked — an untracked push never reads the config at all.
456
+ def track_enqueued(pipe, jobs, now)
457
+ ttl = nil
458
+ jobs.each do |job|
459
+ next unless job['track']
460
+
461
+ ttl ||= Wurk::Status.default_ttl(@config)
462
+ Wurk::Status.enqueued(pipe, job, now, ttl: ttl)
369
463
  end
370
464
  end
371
465
 
372
466
  # Batched jobs route through BATCH_PUSH: increments b-<bid> total+pending,
373
467
  # SADDs jid into the live set, registers the queue, LPUSHes the payload —
374
- # all atomically. One Redis round-trip per job (no pipeline grouping)
375
- # because the lua needs per-job KEYS bound. Acceptable cost: batch
376
- # enqueue is not the hot path; correctness is. `eval_method` is the
468
+ # all atomically. The Lua binds per-job KEYS, so grouping N jobs into one
469
+ # EVALSHA isn't available; they ride one pipeline instead (the `conn` here
470
+ # is always the pipeline #push_batched_pipelined opened), so the cost is
471
+ # N commands and one round trip, not N round trips. `eval_method` is the
377
472
  # Wurk::Lua::Loader entry point (`:eval_cached` for the hot EVALSHA path,
378
- # `:eval_with_source` for the EVAL-source retry).
473
+ # `:eval_with_source` for the EVAL-source retry) — neither reads the reply,
474
+ # which is what makes the pipelined form legal: `eval_cached`'s inline
475
+ # NOSCRIPT rescue can't fire against a buffered call, so recovery is the
476
+ # caller's finalize-time rescue.
379
477
  def push_batched(conn, payloads, now, eval_method: :eval_cached)
380
478
  payloads.each do |j|
381
479
  j['enqueued_at'] = now
@@ -388,15 +486,20 @@ module Wurk
388
486
  argv: [j['queue'], j['jid'], Wurk.dump_json(j), j['bid'], Wurk::Batch::DEFAULT_EXPIRY_SECONDS]
389
487
  )
390
488
  end
489
+ # Same pipeline, so a batched tracked push keeps its one round trip too.
490
+ # This slice is the one that gets replayed on NOSCRIPT (see
491
+ # #eval_batched_slice); HSET + EXPIRE are idempotent, so a replay writes
492
+ # the same row twice rather than double-counting anything.
493
+ track_enqueued(conn, payloads, now)
391
494
  end
392
495
 
393
496
  # Scheduled batched jobs route through BATCH_SCHEDULE: the SADD-guarded
394
497
  # total/pending increment registers the job in its batch at creation, and
395
498
  # the ZADD defers it onto `schedule`. Payload is stripped of `at`/
396
499
  # `enqueued_at` exactly like push_scheduled — `enqueued_at` is stamped fresh
397
- # at promotion, never while the job sits scheduled (spec §7.1). One Redis
398
- # round-trip per job because the Lua binds per-job KEYS; scheduled batch
399
- # enqueue is not the hot path.
500
+ # at promotion, never while the job sits scheduled (spec §7.1). Per-job
501
+ # KEYS again, so one EVALSHA per job — pipelined by
502
+ # #push_batched_scheduled_pipelined into one round trip per slice.
400
503
  def push_batched_scheduled(conn, payloads, eval_method: :eval_cached)
401
504
  payloads.each do |j|
402
505
  Wurk::Lua::Loader.public_send(
@@ -436,7 +539,13 @@ module Wurk
436
539
  # evict them, and the drain that does land one counts it then — so booking
437
540
  # them here would inflate the counter on an outage and double-count every
438
541
  # payload that later replays.
542
+ #
543
+ # Resolve the client once for the whole batch and bail before touching a
544
+ # payload: unconfigured is the common case, and the tags below cost two
545
+ # Strings and an Array per job for `increment` to immediately drop.
439
546
  def emit_enqueued(payloads, buffered = nil)
547
+ return if Wurk::Metrics::Statsd.safe_client.nil?
548
+
440
549
  payloads = reject_by_identity(payloads, buffered) if buffered && !buffered.empty?
441
550
  payloads.each do |p|
442
551
  Wurk::Metrics::Statsd.increment(