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.
- checksums.yaml +4 -4
- data/README.md +41 -3
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- data/app/controllers/wurk/api/serializers.rb +51 -1
- data/app/controllers/wurk/api_controller.rb +42 -6
- data/app/controllers/wurk/dashboard_controller.rb +66 -10
- data/config/routes.rb +30 -3
- data/exe/wurk +6 -2
- data/lib/generators/wurk/install/install_generator.rb +3 -3
- data/lib/sidekiq/job_retry.rb +4 -0
- data/lib/sidekiq/manager.rb +4 -0
- data/lib/sidekiq/processor.rb +4 -0
- data/lib/wurk/api/app.rb +186 -0
- data/lib/wurk/api/auth.rb +167 -0
- data/lib/wurk/api/flows.rb +66 -0
- data/lib/wurk/api/idempotency.rb +168 -0
- data/lib/wurk/api/jobs.rb +180 -0
- data/lib/wurk/api/page.rb +98 -0
- data/lib/wurk/api/problem.rb +121 -0
- data/lib/wurk/api/queues.rb +126 -0
- data/lib/wurk/api/read_only.rb +68 -0
- data/lib/wurk/api/request.rb +46 -0
- data/lib/wurk/api/response.rb +28 -0
- data/lib/wurk/api/roll_up.rb +126 -0
- data/lib/wurk/api/router.rb +88 -0
- data/lib/wurk/api/serializers.rb +232 -0
- data/lib/wurk/api/swarm.rb +297 -0
- data/lib/wurk/api/throttle.rb +109 -0
- data/lib/wurk/api/validation.rb +306 -0
- data/lib/wurk/api.rb +96 -0
- data/lib/wurk/batch/server_middleware.rb +2 -2
- data/lib/wurk/batch.rb +12 -3
- data/lib/wurk/capsule.rb +48 -13
- data/lib/wurk/cli.rb +99 -4
- data/lib/wurk/client/buffered.rb +8 -1
- data/lib/wurk/client.rb +144 -35
- data/lib/wurk/collapse.rb +383 -0
- data/lib/wurk/compat.rb +19 -0
- data/lib/wurk/component.rb +48 -4
- data/lib/wurk/configuration.rb +348 -10
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/debounce.rb +125 -0
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/capped.rb +218 -0
- data/lib/wurk/fetcher/reaper.rb +12 -2
- data/lib/wurk/fetcher/reliable.rb +307 -90
- data/lib/wurk/fetcher/unit_of_work.rb +100 -0
- data/lib/wurk/fetcher.rb +6 -0
- data/lib/wurk/flow/builder.rb +271 -0
- data/lib/wurk/flow/chain.rb +42 -0
- data/lib/wurk/flow/completion.rb +114 -0
- data/lib/wurk/flow/creation.rb +254 -0
- data/lib/wurk/flow/node.rb +124 -0
- data/lib/wurk/flow/status.rb +206 -0
- data/lib/wurk/flow.rb +255 -0
- data/lib/wurk/flow_set.rb +53 -0
- data/lib/wurk/health.rb +8 -7
- data/lib/wurk/heartbeat.rb +23 -5
- data/lib/wurk/job/options.rb +11 -1
- data/lib/wurk/job.rb +19 -0
- data/lib/wurk/job_logger.rb +16 -7
- data/lib/wurk/job_retry.rb +6 -1
- data/lib/wurk/job_set.rb +3 -2
- data/lib/wurk/job_util.rb +153 -32
- data/lib/wurk/keys.rb +125 -0
- data/lib/wurk/launcher.rb +117 -73
- data/lib/wurk/leader.rb +37 -7
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/points.rb +1 -1
- data/lib/wurk/logger.rb +1 -1
- data/lib/wurk/lua/debounce.lua +75 -0
- data/lib/wurk/lua/fetch_slot.lua +83 -0
- data/lib/wurk/lua/flow_abandon.lua +65 -0
- data/lib/wurk/lua/flow_advance.lua +177 -0
- data/lib/wurk/lua/flow_create.lua +141 -0
- data/lib/wurk/lua/flow_fail.lua +52 -0
- data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
- data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
- data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
- data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
- data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
- data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
- data/lib/wurk/lua/limiter_points_refund.lua +18 -0
- data/lib/wurk/lua/limiter_register.lua +27 -0
- data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
- data/lib/wurk/lua/limiter_window_status.lua +22 -0
- data/lib/wurk/lua/loader.rb +32 -3
- data/lib/wurk/lua/queue_slot.lua +83 -0
- data/lib/wurk/lua/refresh_slots.lua +38 -0
- data/lib/wurk/lua/status_write.lua +29 -0
- data/lib/wurk/lua/throttle_slot.lua +71 -0
- data/lib/wurk/lua.rb +3 -3
- data/lib/wurk/manager.rb +12 -0
- data/lib/wurk/metrics/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +132 -31
- data/lib/wurk/metrics/query.rb +1 -1
- data/lib/wurk/metrics/statsd.rb +46 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/expiry.rb +71 -10
- data/lib/wurk/middleware/poison_pill.rb +23 -2
- data/lib/wurk/middleware/status.rb +274 -0
- data/lib/wurk/middleware/timeout.rb +137 -0
- data/lib/wurk/pool_checkout.rb +10 -0
- data/lib/wurk/processor.rb +141 -29
- data/lib/wurk/profiler.rb +6 -4
- data/lib/wurk/queue.rb +8 -0
- data/lib/wurk/queue_slot.rb +285 -0
- data/lib/wurk/rails.rb +3 -3
- data/lib/wurk/redis_client_adapter.rb +1 -1
- data/lib/wurk/redis_pool.rb +2 -1
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +5 -1
- data/lib/wurk/status/progress.rb +103 -0
- data/lib/wurk/status/record.rb +81 -0
- data/lib/wurk/status.rb +155 -0
- data/lib/wurk/swarm/child_boot.rb +24 -4
- data/lib/wurk/swarm.rb +88 -14
- data/lib/wurk/telemetry/client_middleware.rb +59 -0
- data/lib/wurk/telemetry/server_middleware.rb +152 -0
- data/lib/wurk/telemetry.rb +137 -0
- data/lib/wurk/throttle.rb +142 -0
- data/lib/wurk/unique.rb +10 -2
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/watchdog.rb +188 -0
- data/lib/wurk/web/config.rb +88 -17
- data/lib/wurk/web/extension.rb +2 -2
- data/lib/wurk/web/locale_negotiator.rb +67 -0
- data/lib/wurk/web.rb +1 -0
- data/lib/wurk/worker.rb +39 -4
- data/lib/wurk.rb +42 -9
- data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
- data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
- data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
- data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
- data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
- data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
- data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
- data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
- data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
- data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
- data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
- data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
- data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
- data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
- data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
- data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
- data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
- data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
- data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
- data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
- data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
- data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
- data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
- data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
- data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
- data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
- data/vendor/assets/dashboard/index.html +42 -11
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +105 -34
- data/vendor/assets/dashboard/assets/ArgsValue-D74zX0MI.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-OmC5NPgw.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-CIpai7St.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-A_kwSR6Q.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-BG7HTqlp.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-8J21jMyK.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-Fh_Ae7UW.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-Ceng0PMB.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-CruDWvNZ.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-C44KNMGm.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-xEVTyS2N.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-D86FYohJ.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-Bz1O1D-i.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-B6h2akTu.js +0 -1
- data/vendor/assets/dashboard/assets/Search-OOu22e5s.js +0 -1
- data/vendor/assets/dashboard/assets/Skeleton-DzR7XNxz.js +0 -1
- data/vendor/assets/dashboard/assets/charts-BVHHGof7.js +0 -1
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
- data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +0 -141
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-dVPGEWzn.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-BeYbztkN.js +0 -1
- 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
|
-
|
|
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
|
data/lib/wurk/client/buffered.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
conn.pipelined { |pipe|
|
|
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|
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
375
|
-
#
|
|
376
|
-
#
|
|
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).
|
|
398
|
-
#
|
|
399
|
-
#
|
|
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(
|