wurk 1.8.1 → 1.10.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 +15 -13
- data/app/controllers/wurk/api_controller.rb +53 -11
- data/config/routes.rb +20 -12
- data/lib/active_job/queue_adapters/wurk_adapter.rb +8 -2
- data/lib/sidekiq/rails.rb +5 -0
- data/lib/wurk/api/read_only.rb +1 -1
- data/lib/wurk/batch/server_middleware.rb +27 -0
- data/lib/wurk/boot_checks.rb +87 -0
- data/lib/wurk/capsule.rb +84 -4
- data/lib/wurk/cli.rb +77 -10
- data/lib/wurk/client/buffered.rb +240 -26
- data/lib/wurk/client.rb +16 -11
- data/lib/wurk/compat.rb +23 -2
- data/lib/wurk/configuration.rb +193 -16
- data/lib/wurk/cron.rb +45 -0
- data/lib/wurk/debounce.rb +2 -2
- data/lib/wurk/encryption.rb +47 -8
- data/lib/wurk/error_coalescer.rb +35 -0
- data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
- data/lib/wurk/fetcher/capped.rb +4 -0
- data/lib/wurk/fetcher/many_queues.rb +17 -1
- data/lib/wurk/fetcher/oom_drain.rb +102 -0
- data/lib/wurk/fetcher/orphan_grace.rb +60 -44
- data/lib/wurk/fetcher/private_list_key.rb +17 -15
- data/lib/wurk/fetcher/reaper.rb +195 -129
- data/lib/wurk/fetcher/reliable.rb +26 -4
- data/lib/wurk/fetcher/self_reconcile.rb +175 -0
- data/lib/wurk/fetcher/unit_of_work.rb +32 -8
- data/lib/wurk/fetcher/unparseable_keys.rb +22 -7
- data/lib/wurk/fetcher.rb +6 -1
- data/lib/wurk/health.rb +26 -3
- data/lib/wurk/health_probe.rb +64 -0
- data/lib/wurk/heartbeat.rb +55 -3
- data/lib/wurk/job/options.rb +9 -1
- data/lib/wurk/job.rb +2 -24
- data/lib/wurk/job_logger.rb +1 -1
- data/lib/wurk/job_record.rb +17 -9
- data/lib/wurk/job_retry.rb +38 -10
- data/lib/wurk/job_set.rb +13 -5
- data/lib/wurk/job_util.rb +4 -1
- data/lib/wurk/launcher.rb +138 -55
- data/lib/wurk/leader.rb +9 -3
- data/lib/wurk/limiter/base.rb +17 -1
- data/lib/wurk/lua/debounce.lua +11 -6
- data/lib/wurk/lua/loader.rb +13 -1
- data/lib/wurk/lua.rb +72 -22
- data/lib/wurk/manager.rb +1 -1
- data/lib/wurk/metrics/dashboard_series.rb +21 -2
- data/lib/wurk/metrics/history.rb +13 -5
- data/lib/wurk/metrics/process_counters.rb +27 -0
- data/lib/wurk/metrics/prometheus.rb +35 -14
- data/lib/wurk/metrics/query.rb +9 -2
- data/lib/wurk/metrics/statsd.rb +8 -0
- data/lib/wurk/middleware/chain.rb +85 -13
- data/lib/wurk/middleware/current_attributes.rb +26 -10
- data/lib/wurk/middleware/poison_pill.rb +1 -2
- data/lib/wurk/pool_checkout.rb +1 -1
- data/lib/wurk/process_set.rb +3 -1
- data/lib/wurk/processor.rb +82 -18
- data/lib/wurk/queue_consumers.rb +134 -0
- data/lib/wurk/queue_slot.rb +19 -9
- data/lib/wurk/rails_boot.rb +44 -5
- data/lib/wurk/rails_reloader.rb +51 -0
- data/lib/wurk/railtie.rb +27 -4
- data/lib/wurk/redact.rb +12 -0
- data/lib/wurk/redis_connection.rb +4 -2
- data/lib/wurk/redis_pool.rb +34 -9
- data/lib/wurk/scheduled.rb +142 -67
- data/lib/wurk/sentry/death_handler.rb +29 -0
- data/lib/wurk/sentry/error_handler.rb +52 -10
- data/lib/wurk/sentry/middleware.rb +8 -1
- data/lib/wurk/sentry/retry_policy.rb +12 -3
- data/lib/wurk/sentry.rb +42 -5
- data/lib/wurk/sorted_entry.rb +38 -13
- data/lib/wurk/swarm/child_boot.rb +42 -31
- data/lib/wurk/swarm/crash_watch.rb +65 -0
- data/lib/wurk/swarm.rb +79 -11
- data/lib/wurk/testing.rb +66 -0
- data/lib/wurk/unique.rb +11 -8
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +42 -9
- data/lib/wurk/web/enterprise.rb +4 -3
- data/lib/wurk/web/extension.rb +80 -40
- data/lib/wurk/work_set.rb +6 -0
- data/lib/wurk/worker/setter.rb +11 -6
- data/lib/wurk/worker.rb +17 -1
- data/lib/wurk.rb +6 -3
- data/vendor/assets/dashboard/assets/{ArgsValue-_MYkd_vA.js → ArgsValue-CmaXArv8.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-DPmg-hle.js → BatchDetail-nc5yePZW.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-NTgqSlvD.js → Batches-DgEcyOZ_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-2rxcLJMq.js → Busy-CUZncvCy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-BFpJgKYb.js → Cron-CIZbaaZs.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dashboard-C8VOE2S7.js → Dashboard-BWmfAook.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dead-Dse2YEbZ.js → Dead-CGKsTZHq.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-Doy4bJoA.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-C4bO26_d.js +2 -0
- data/vendor/assets/dashboard/assets/{FlowDetail-BWddOtgf.js → FlowDetail-Dh3SJdFh.js} +1 -1
- data/vendor/assets/dashboard/assets/{FlowState-6lGsojxE.js → FlowState-yb7virqR.js} +1 -1
- data/vendor/assets/dashboard/assets/{Flows-BgKTlYwI.js → Flows-C5SMRuhq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-BrjmO98c.js → JobDetailModal-Cg6sAsJB.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-DeUkfbcg.js → Limiters-BIxwDOJM.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-Qp9q0nF8.js +1 -0
- data/vendor/assets/dashboard/assets/{NotFound-CQLLXIfa.js → NotFound-DwjPxFjU.js} +1 -1
- data/vendor/assets/dashboard/assets/{PageHeader-CeSaqysY.js → PageHeader-vWeBbSPF.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-CI2i2tl8.js → Profiles-7dabYVe3.js} +1 -1
- data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
- data/vendor/assets/dashboard/assets/{Retries-Dc2RFuTf.js → Retries-CNX8ZtTH.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-B8zOKCLC.js → Scheduled-ClVBj0nc.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-x0eS-V-U.js → Search-BAsVE2qw.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-VggSMFGc.js → charts-DZs138BV.js} +1 -1
- data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
- data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
- data/vendor/assets/dashboard/assets/index-CMzPFxNE.js +141 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BsYWeStI.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-BWWfpnZH.js → useSort-jBYVwXvg.js} +1 -1
- data/vendor/assets/dashboard/assets/{utils-DnzJXljj.js → utils-B0kQFNE8.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +41 -44
- data/vendor/assets/dashboard/assets/Extension-BIc2c1zG.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-BfZm8ZzH.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-DzmAgBFG.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-DUCQ_3St.js +0 -1
- data/vendor/assets/dashboard/assets/i18n-B6AbnmJn.js +0 -1
- data/vendor/assets/dashboard/assets/index-CTWV8VL4.js +0 -141
- data/vendor/assets/dashboard/assets/index-WpZjU076.css +0 -1
data/lib/wurk/cli.rb
CHANGED
|
@@ -11,6 +11,8 @@ require_relative 'configuration'
|
|
|
11
11
|
require_relative 'component'
|
|
12
12
|
require_relative 'capsule'
|
|
13
13
|
require_relative 'launcher'
|
|
14
|
+
require_relative 'rails_boot'
|
|
15
|
+
require_relative 'boot_checks'
|
|
14
16
|
|
|
15
17
|
module Wurk
|
|
16
18
|
# Standalone CLI. Loads from `exe/wurk` — never loads `wurk/rails` so the
|
|
@@ -29,7 +31,7 @@ module Wurk
|
|
|
29
31
|
# Subcommands. Bare `wurk` runs the worker — the historical shape, and the
|
|
30
32
|
# only one the swarm binaries accept — so a subcommand is never anything
|
|
31
33
|
# but the first argument, and every argument after it is that command's.
|
|
32
|
-
COMMANDS = %w[api].freeze
|
|
34
|
+
COMMANDS = %w[api health].freeze
|
|
33
35
|
|
|
34
36
|
# `wurk api` defaults. 0.0.0.0 because the point of the machine API is for
|
|
35
37
|
# another service to reach it; it is bearer-gated, and answers 404 to
|
|
@@ -99,6 +101,15 @@ module Wurk
|
|
|
99
101
|
['-s', '--server NAME', :api_server, 'Rack handler to serve with (default: the first installed)']
|
|
100
102
|
].freeze
|
|
101
103
|
|
|
104
|
+
HEALTH_OPTION_FLAGS = [
|
|
105
|
+
['-w', '--window SECONDS', :health_window, 'A heartbeat older than this does not count (default 60)', :to_i],
|
|
106
|
+
['-m', '--min COUNT', :health_min, 'Processes on this host that must be beating (default 1)', :to_i],
|
|
107
|
+
['-u', '--redis-url URL', :health_redis_url, 'Redis to read (default REDIS_URL)'],
|
|
108
|
+
['-s', '--strict', :health_strict, 'Fail when Redis cannot be read, rather than passing']
|
|
109
|
+
].freeze
|
|
110
|
+
|
|
111
|
+
HELP_COMMANDS = "\nCommands:\n health Exit 0 if this host's workers are heartbeating\n\nOptions:"
|
|
112
|
+
|
|
102
113
|
attr_accessor :launcher, :environment, :config
|
|
103
114
|
|
|
104
115
|
# The subcommand `parse` pulled off argv, or nil for the worker runner.
|
|
@@ -127,6 +138,8 @@ module Wurk
|
|
|
127
138
|
def parse(args = ARGV.dup)
|
|
128
139
|
@config ||= Wurk.default_configuration
|
|
129
140
|
@command = extract_command!(args)
|
|
141
|
+
return parse_health_options(args) if @command == 'health'
|
|
142
|
+
|
|
130
143
|
setup_options(args)
|
|
131
144
|
initialize_logger
|
|
132
145
|
validate!
|
|
@@ -134,12 +147,16 @@ module Wurk
|
|
|
134
147
|
end
|
|
135
148
|
|
|
136
149
|
# `boot_app:` / `warmup:` are test seams. Production always passes true.
|
|
137
|
-
def run(boot_app: true, warmup: true)
|
|
150
|
+
def run(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
|
|
151
|
+
exit(run_health) if @command == 'health'
|
|
152
|
+
|
|
138
153
|
# Mark server mode BEFORE the app loads so `configure_server` blocks in
|
|
139
154
|
# the required initializer actually fire (they gate on `config.server?`).
|
|
140
155
|
# Matches Sidekiq, which sets `Sidekiq.server = true` before requiring
|
|
141
|
-
# the app in its CLI. Skipping this
|
|
142
|
-
#
|
|
156
|
+
# the app in its CLI. Skipping this drops server middleware, error
|
|
157
|
+
# handlers, and lifecycle hooks registered via configure_server, and each
|
|
158
|
+
# skip logs one WARN naming the gate (`configure_server` / `server? is
|
|
159
|
+
# false`).
|
|
143
160
|
enter_server_mode
|
|
144
161
|
boot_application if boot_app
|
|
145
162
|
self_read, self_write = ::IO.pipe
|
|
@@ -147,6 +164,7 @@ module Wurk
|
|
|
147
164
|
trap_signals(self_write)
|
|
148
165
|
validate_redis!
|
|
149
166
|
validate_pool_sizes!
|
|
167
|
+
BootChecks.run(@config, redis_info)
|
|
150
168
|
@config[:identity] = identity
|
|
151
169
|
# Force lazy server-middleware chain so worker threads don't race
|
|
152
170
|
# against each other constructing it. Spec: Sidekiq::CLI line 104.
|
|
@@ -173,23 +191,28 @@ module Wurk
|
|
|
173
191
|
# (`WURK_PRELOAD`/`SIDEKIQ_PRELOAD` Bundler groups, `WURK_PRELOAD_APP`/
|
|
174
192
|
# `SIDEKIQ_PRELOAD_APP` whole-app eager-load) and boots `Process.warmup`
|
|
175
193
|
# before the fork so children share warmed pages (copy-on-write). Spec §7.
|
|
176
|
-
def run_swarm(boot_app: true, warmup: true)
|
|
194
|
+
def run_swarm(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
|
|
177
195
|
raise ArgumentError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
|
|
178
196
|
|
|
179
197
|
# Server mode before the app loads — see #run. The flag rides through the
|
|
180
198
|
# fork into every child (the config object is copied), so configure_server
|
|
181
199
|
# blocks registered in the parent take effect in the workers.
|
|
182
200
|
enter_server_mode
|
|
201
|
+
# Only the children read it. Rejected here, a typo fails this boot instead of
|
|
202
|
+
# crash-looping the fleet until the crash-loop exit.
|
|
203
|
+
Health.options_from_env
|
|
183
204
|
if boot_app
|
|
205
|
+
load_rails_framework
|
|
184
206
|
preload_bundler_groups
|
|
185
207
|
boot_application
|
|
186
208
|
eager_load_application
|
|
187
209
|
end
|
|
188
210
|
validate_redis!
|
|
189
211
|
validate_pool_sizes!
|
|
212
|
+
BootChecks.run(@config, redis_info)
|
|
190
213
|
@config[:identity] = identity
|
|
191
214
|
warm_up_process if warmup
|
|
192
|
-
@swarm = Wurk::Swarm.new(topology: @config.topology, config: @config,
|
|
215
|
+
@swarm = Wurk::Swarm.new(topology: @config.topology, config: @config, exit_on_crash_loop: true,
|
|
193
216
|
shutdown_timeout: @config[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
|
|
194
217
|
begin
|
|
195
218
|
@swarm.boot(install_signals: true)
|
|
@@ -200,6 +223,16 @@ module Wurk
|
|
|
200
223
|
# A no-op once the loop already drained (no children, pipe closed).
|
|
201
224
|
@swarm.shutdown
|
|
202
225
|
end
|
|
226
|
+
exit(1) if @swarm.crash_looped?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# `wurk health` — see Wurk::HealthProbe. Boots nothing; returns the exit code.
|
|
230
|
+
def run_health
|
|
231
|
+
require_relative 'health_probe'
|
|
232
|
+
opts = @health_options
|
|
233
|
+
@config.redis = { url: opts[:health_redis_url] } if opts[:health_redis_url]
|
|
234
|
+
HealthProbe.new(@config, window: opts[:health_window] || HealthProbe::DEFAULT_WINDOW,
|
|
235
|
+
min: opts[:health_min] || 1, strict: opts[:health_strict] == true).call
|
|
203
236
|
end
|
|
204
237
|
|
|
205
238
|
# `wurk api` — serve the machine-facing HTTP API and nothing else: no
|
|
@@ -327,8 +360,8 @@ module Wurk
|
|
|
327
360
|
def reopen_logs
|
|
328
361
|
log = logger
|
|
329
362
|
log.reopen if log.respond_to?(:reopen)
|
|
330
|
-
rescue StandardError
|
|
331
|
-
|
|
363
|
+
rescue StandardError => e
|
|
364
|
+
warn "wurk: USR2 log reopen failed, still writing to the previous log: #{e.class}: #{e.message}"
|
|
332
365
|
end
|
|
333
366
|
|
|
334
367
|
def validate_redis!
|
|
@@ -370,6 +403,14 @@ module Wurk
|
|
|
370
403
|
COMMANDS.include?(args.first) ? args.shift : nil
|
|
371
404
|
end
|
|
372
405
|
|
|
406
|
+
def parse_health_options(args)
|
|
407
|
+
@health_options = {}
|
|
408
|
+
parser = option_parser(@health_options)
|
|
409
|
+
@parser = parser
|
|
410
|
+
parser.parse!(args)
|
|
411
|
+
self
|
|
412
|
+
end
|
|
413
|
+
|
|
373
414
|
def setup_options(args)
|
|
374
415
|
opts = parse_options(args)
|
|
375
416
|
set_environment(opts[:environment])
|
|
@@ -456,8 +497,9 @@ module Wurk
|
|
|
456
497
|
require 'optparse'
|
|
457
498
|
|
|
458
499
|
::OptionParser.new do |o|
|
|
459
|
-
o.banner = @command ? "wurk #{@command} [options]" :
|
|
460
|
-
|
|
500
|
+
o.banner = @command ? "wurk #{@command} [options]" : "wurk [options]\nwurk COMMAND [options]"
|
|
501
|
+
o.separator HELP_COMMANDS unless @command
|
|
502
|
+
define_value_flags(o, opts, @command == 'health' ? HEALTH_OPTION_FLAGS : OPTION_FLAGS)
|
|
461
503
|
define_value_flags(o, opts, API_OPTION_FLAGS) if @command == 'api'
|
|
462
504
|
define_meta_flags(o, opts)
|
|
463
505
|
end
|
|
@@ -490,6 +532,28 @@ module Wurk
|
|
|
490
532
|
|
|
491
533
|
# --- application bootstrap --------------------------------------------
|
|
492
534
|
|
|
535
|
+
# A Rails app expects Rails to be loaded before its gems: `config/application.rb`
|
|
536
|
+
# requires the framework and only then calls `Bundler.require`, and gems read
|
|
537
|
+
# `defined?(Rails)` or reference ActiveModel while loading. Requiring the groups
|
|
538
|
+
# first broke those gems in this boot path only. So for a Rails target, run
|
|
539
|
+
# the app's own first half before the group preload.
|
|
540
|
+
def load_rails_framework
|
|
541
|
+
root = rails_app_root(@config[:require])
|
|
542
|
+
return unless root
|
|
543
|
+
|
|
544
|
+
ENV['RACK_ENV'] = ENV['RAILS_ENV'] = @environment
|
|
545
|
+
require 'rails'
|
|
546
|
+
define_active_job_adapter
|
|
547
|
+
require ::File.join(root, 'config/application.rb')
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# The app root for a `-r` that is the app directory or its environment file.
|
|
551
|
+
def rails_app_root(require_path)
|
|
552
|
+
path = ::File.expand_path(require_path.to_s)
|
|
553
|
+
path = ::File.dirname(path, 2) if ::File.basename(path) == 'environment.rb'
|
|
554
|
+
path if ::File.directory?(path) && ::File.exist?(::File.join(path, 'config/application.rb'))
|
|
555
|
+
end
|
|
556
|
+
|
|
493
557
|
# Spec §7.2/§7.3. Before the swarm parent forks, `Bundler.require` the
|
|
494
558
|
# configured groups so their gems are resident in the parent and paged
|
|
495
559
|
# copy-on-write into every child. Comma-separated group list; the native
|
|
@@ -552,6 +616,9 @@ module Wurk
|
|
|
552
616
|
def boot_rails_application(require_path)
|
|
553
617
|
require 'rails'
|
|
554
618
|
define_active_job_adapter
|
|
619
|
+
# No railtie here (see boot_application), and a host's after_initialize hook
|
|
620
|
+
# may wrap the reloader, so it has to be the Rails one by then.
|
|
621
|
+
::ActiveSupport.on_load(:after_initialize, yield: true) { |app| RailsBoot.install_reloader(app) }
|
|
555
622
|
require ::File.expand_path("#{require_path}/config/environment.rb")
|
|
556
623
|
@config[:tag] ||= default_tag(::Rails.root) if defined?(::Rails) && ::Rails.respond_to?(:root)
|
|
557
624
|
end
|
data/lib/wurk/client/buffered.rb
CHANGED
|
@@ -9,15 +9,28 @@ module Wurk
|
|
|
9
9
|
# creation or batch-context pushes (`bid` on payload): BATCH_PUSH has
|
|
10
10
|
# atomic counter side-effects we can't safely replay.
|
|
11
11
|
#
|
|
12
|
+
# A failed push opens a circuit for that Redis: later pushes buffer without
|
|
13
|
+
# touching the socket, so one caller pays the connection backoff and the rest
|
|
14
|
+
# do not. The Drainer thread, started on the first failure if none is running,
|
|
15
|
+
# is what probes the server and closes the circuit.
|
|
16
|
+
#
|
|
12
17
|
# One buffer serves every pool, so each entry remembers the Redis it was
|
|
13
18
|
# headed for (its {Origin}) and is only ever replayed there: a sharded app
|
|
14
19
|
# pushing through `Client.via(shard_a)` must not have its outage backlog
|
|
15
20
|
# land on shard B because B's producer happened to push next.
|
|
16
21
|
#
|
|
17
22
|
# Spec: docs/target/sidekiq-pro.md §5.
|
|
18
|
-
module Buffered
|
|
23
|
+
module Buffered # rubocop:disable Metrics/ModuleLength
|
|
19
24
|
DEFAULT_BUFFER_CAP = 1_000
|
|
20
25
|
DRAINING_KEY = :wurk_reliable_push_draining
|
|
26
|
+
UNBUFFERED_KEY = :wurk_reliable_push_unbuffered
|
|
27
|
+
|
|
28
|
+
# A clean exit tries to deliver what is still in memory, but never holds the
|
|
29
|
+
# process up for longer than this: a hung Redis would otherwise stall shutdown.
|
|
30
|
+
EXIT_FLUSH_SECONDS = 5
|
|
31
|
+
# Paces flush's retries as well as its checks, so a server that keeps refusing
|
|
32
|
+
# gets a few attempts in that window and not hundreds of reports.
|
|
33
|
+
FLUSH_POLL = 0.25
|
|
21
34
|
|
|
22
35
|
# Overflow modes. `:drop_oldest` is the spec default (Sidekiq Pro §5
|
|
23
36
|
# ring buffer). `:raise` lets callers decide what to do on backpressure
|
|
@@ -70,6 +83,67 @@ module Wurk
|
|
|
70
83
|
end
|
|
71
84
|
end
|
|
72
85
|
|
|
86
|
+
# Which Redis servers are refusing connections, by pool identity. An entry
|
|
87
|
+
# exists only while its circuit is open, so a rebuilt pool leaves nothing behind.
|
|
88
|
+
class Circuit
|
|
89
|
+
# A circuit closes itself after this long, so a dead drainer costs a caller
|
|
90
|
+
# one attempt per window instead of buffering forever. A failed probe renews it.
|
|
91
|
+
OPEN_SECONDS = 30.0
|
|
92
|
+
|
|
93
|
+
def initialize
|
|
94
|
+
@lock = Mutex.new
|
|
95
|
+
@open_until = {}.compare_by_identity
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def open?(pool)
|
|
99
|
+
return false if @open_until.empty?
|
|
100
|
+
|
|
101
|
+
@lock.synchronize do
|
|
102
|
+
deadline = @open_until[pool]
|
|
103
|
+
next false unless deadline
|
|
104
|
+
next true if monotonic < deadline
|
|
105
|
+
|
|
106
|
+
@open_until.delete(pool)
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def trip(pool)
|
|
112
|
+
@lock.synchronize { @open_until[pool] = monotonic + OPEN_SECONDS }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def close(pool)
|
|
116
|
+
@lock.synchronize { @open_until.delete(pool) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def any_open?
|
|
120
|
+
!@open_until.empty?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# For a circuit with nothing buffered behind it (a batched push tripped it,
|
|
124
|
+
# or `:raise` overflow kept the buffer empty): no replay exists to prove the
|
|
125
|
+
# server is back. Any answer means reachable, and an error such as LOADING
|
|
126
|
+
# belongs to the next real push to report, not to the circuit.
|
|
127
|
+
def ping_all(except:)
|
|
128
|
+
@lock.synchronize { @open_until.keys }.each { |pool| ping(pool) unless except.any? { |o| o.equal?(pool) } }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def ping(pool)
|
|
134
|
+
pool.with { |conn| conn.call('PING') }
|
|
135
|
+
close(pool)
|
|
136
|
+
rescue RedisClient::ConnectionError, ConnectionPool::TimeoutError
|
|
137
|
+
trip(pool)
|
|
138
|
+
rescue RedisClient::CommandError
|
|
139
|
+
close(pool)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def monotonic
|
|
143
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
73
147
|
# Eagerly initialized: `||=` inside an accessor is not atomic — two
|
|
74
148
|
# threads racing first-touch could end up holding distinct Mutex
|
|
75
149
|
# instances and lose all synchronization on the shared buffer.
|
|
@@ -84,6 +158,8 @@ module Wurk
|
|
|
84
158
|
# buys immunity from both.
|
|
85
159
|
@install_mutex = Mutex.new
|
|
86
160
|
@buffer_mutex = Mutex.new
|
|
161
|
+
@replaying = 0
|
|
162
|
+
@circuit = Circuit.new
|
|
87
163
|
|
|
88
164
|
# Process that owns the state above; a mismatch means we're running in a
|
|
89
165
|
# fork and the inherited copy has to go.
|
|
@@ -99,6 +175,7 @@ module Wurk
|
|
|
99
175
|
|
|
100
176
|
Wurk::Client.prepend(InstanceMethods)
|
|
101
177
|
@installed = true
|
|
178
|
+
at_exit { Buffered.flush }
|
|
102
179
|
end
|
|
103
180
|
end
|
|
104
181
|
|
|
@@ -145,7 +222,9 @@ module Wurk
|
|
|
145
222
|
@buffer = []
|
|
146
223
|
@buffer_cap = nil
|
|
147
224
|
@overflow_mode = nil
|
|
225
|
+
@replaying = 0
|
|
148
226
|
end
|
|
227
|
+
@circuit = Circuit.new
|
|
149
228
|
@drops = DropLog.new
|
|
150
229
|
# Stop before dropping: an unstopped drainer thread would otherwise
|
|
151
230
|
# tick on forever, unreachable, leaking the thread.
|
|
@@ -184,8 +263,11 @@ module Wurk
|
|
|
184
263
|
@install_mutex = Mutex.new
|
|
185
264
|
@buffer_mutex = Mutex.new
|
|
186
265
|
@buffer = []
|
|
266
|
+
@replaying = 0
|
|
267
|
+
@circuit = Circuit.new
|
|
187
268
|
@drops = DropLog.new
|
|
188
|
-
interval = @drainer&.interval
|
|
269
|
+
interval = @drainer&.interval unless @implicit_drainer
|
|
270
|
+
@implicit_drainer = false
|
|
189
271
|
@drainer = nil
|
|
190
272
|
start_drainer!(interval: interval) if interval
|
|
191
273
|
true
|
|
@@ -258,32 +340,63 @@ module Wurk
|
|
|
258
340
|
# not emit — the replay is where the job actually reaches Redis, so a
|
|
259
341
|
# buffered-then-drained job counts once as enqueued and once as
|
|
260
342
|
# recovered.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
343
|
+
#
|
|
344
|
+
# Skipped outright while the circuit is open, unless this is the probe.
|
|
345
|
+
def drain!(client, probe: false)
|
|
264
346
|
target = client.send(:pool)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
break unless replay(client, entry)
|
|
347
|
+
return 0 if !probe && circuit.open?(target)
|
|
348
|
+
return 0 if buffer_mutex.synchronize { buffer.empty? }
|
|
268
349
|
|
|
269
|
-
|
|
270
|
-
end
|
|
350
|
+
drained = replay_all(client, target)
|
|
271
351
|
drops.end_burst if drained.positive?
|
|
272
352
|
drained
|
|
273
353
|
end
|
|
274
354
|
|
|
355
|
+
# Pushes inside the block reach Redis or raise. For a caller that goes on to
|
|
356
|
+
# delete its own copy of the job once push returns, which must not count
|
|
357
|
+
# memory as delivered: a crash would then lose the only copy.
|
|
358
|
+
def unbuffered
|
|
359
|
+
previous = Thread.current[UNBUFFERED_KEY]
|
|
360
|
+
Thread.current[UNBUFFERED_KEY] = true
|
|
361
|
+
yield
|
|
362
|
+
ensure
|
|
363
|
+
Thread.current[UNBUFFERED_KEY] = previous
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def unbuffered?
|
|
367
|
+
Thread.current[UNBUFFERED_KEY] == true
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Replays whatever is still buffered, whichever circuit it is behind, for up
|
|
371
|
+
# to `timeout`. Returns how many jobs are left. Runs on a clean exit; a
|
|
372
|
+
# Launcher or host shutting down earlier can call it too. Forked children
|
|
373
|
+
# inherit the at_exit hook, but start with an empty buffer of their own.
|
|
374
|
+
def flush(timeout: EXIT_FLUSH_SECONDS)
|
|
375
|
+
return 0 if undelivered_count.zero?
|
|
376
|
+
|
|
377
|
+
retry_until_delivered(flush_clock + timeout)
|
|
378
|
+
undelivered_count.tap { |left| report_undelivered(left) if left.positive? }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def trip!(pool)
|
|
382
|
+
circuit.trip(pool)
|
|
383
|
+
ensure_drainer
|
|
384
|
+
end
|
|
385
|
+
|
|
275
386
|
# The background drainer's pass: every origin in the buffer, each
|
|
276
387
|
# through a client of its own, so one Redis still down does not hold
|
|
277
388
|
# back another that has recovered. Returns the total replayed; the
|
|
278
389
|
# first non-transient error is re-raised after every origin had its turn.
|
|
279
390
|
def drain_all!
|
|
280
391
|
failure = nil
|
|
281
|
-
|
|
282
|
-
|
|
392
|
+
pools = buffered_pools
|
|
393
|
+
drained = pools.sum do |pool|
|
|
394
|
+
drain!(Wurk::Client.new(pool: pool), probe: true)
|
|
283
395
|
rescue StandardError => e
|
|
284
396
|
failure ||= e
|
|
285
397
|
0
|
|
286
398
|
end
|
|
399
|
+
circuit.ping_all(except: pools)
|
|
287
400
|
raise failure if failure
|
|
288
401
|
|
|
289
402
|
drained
|
|
@@ -294,7 +407,7 @@ module Wurk
|
|
|
294
407
|
@buffer ||= []
|
|
295
408
|
end
|
|
296
409
|
|
|
297
|
-
attr_reader :drops
|
|
410
|
+
attr_reader :drops, :circuit
|
|
298
411
|
|
|
299
412
|
# Start a background drain thread that wakes every `interval`
|
|
300
413
|
# seconds and tries to flush the buffer. Idempotent — replaces
|
|
@@ -305,6 +418,7 @@ module Wurk
|
|
|
305
418
|
def start_drainer!(interval: Drainer::DEFAULT_INTERVAL)
|
|
306
419
|
install_mutex.synchronize do
|
|
307
420
|
@drainer&.stop
|
|
421
|
+
@implicit_drainer = false
|
|
308
422
|
@drainer = Drainer.new(interval: interval)
|
|
309
423
|
@drainer.start
|
|
310
424
|
end
|
|
@@ -325,14 +439,83 @@ module Wurk
|
|
|
325
439
|
|
|
326
440
|
attr_reader :install_mutex, :buffer_mutex
|
|
327
441
|
|
|
442
|
+
# A replay holds its entry outside the buffer until it lands or goes back, so
|
|
443
|
+
# an empty buffer alone does not mean every job reached Redis.
|
|
444
|
+
def undelivered_count
|
|
445
|
+
buffer_mutex.synchronize { buffer.size + @replaying }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def retry_until_delivered(deadline)
|
|
449
|
+
worker = nil
|
|
450
|
+
until undelivered_count.zero? || flush_clock >= deadline
|
|
451
|
+
worker = start_flush_attempt unless worker&.alive? || buffer_size.zero?
|
|
452
|
+
sleep(FLUSH_POLL)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def flush_clock
|
|
457
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def start_flush_attempt
|
|
461
|
+
Thread.new do
|
|
462
|
+
drain_all!
|
|
463
|
+
rescue StandardError => e
|
|
464
|
+
Wurk.configuration.handle_exception(e, { context: 'reliable_push flush' })
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def report_undelivered(left)
|
|
469
|
+
Wurk.configuration.logger.error do
|
|
470
|
+
"reliable_push: #{left} buffered job(s) could not reach Redis before exit and are lost"
|
|
471
|
+
end
|
|
472
|
+
rescue StandardError
|
|
473
|
+
nil
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Leaves the circuit open on the first failed replay and closed once the
|
|
477
|
+
# backlog for this Redis has run out.
|
|
478
|
+
def replay_all(client, target)
|
|
479
|
+
drained = 0
|
|
480
|
+
while (entry = take_next(target))
|
|
481
|
+
unless replay(client, entry)
|
|
482
|
+
trip!(target)
|
|
483
|
+
return drained
|
|
484
|
+
end
|
|
485
|
+
drained += 1
|
|
486
|
+
end
|
|
487
|
+
circuit.close(target)
|
|
488
|
+
drained
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# An app's own drainer keeps its interval; only the absence of one makes
|
|
492
|
+
# this implicit, which is what stops `reset_after_fork!` re-arming it in
|
|
493
|
+
# children that never pushed.
|
|
494
|
+
def ensure_drainer
|
|
495
|
+
install_mutex.synchronize do
|
|
496
|
+
next if @drainer&.running?
|
|
497
|
+
|
|
498
|
+
@implicit_drainer = @drainer.nil?
|
|
499
|
+
@drainer ||= Drainer.new
|
|
500
|
+
@drainer.start
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
328
504
|
# The oldest entry bound for `target`, removed from the buffer.
|
|
329
505
|
def take_next(target)
|
|
330
506
|
buffer_mutex.synchronize do
|
|
331
507
|
index = buffer.index { |entry| entry.origin.resolve.equal?(target) }
|
|
332
|
-
|
|
508
|
+
next unless index
|
|
509
|
+
|
|
510
|
+
@replaying += 1
|
|
511
|
+
buffer.delete_at(index)
|
|
333
512
|
end
|
|
334
513
|
end
|
|
335
514
|
|
|
515
|
+
def replay_settled
|
|
516
|
+
buffer_mutex.synchronize { @replaying = [@replaying - 1, 0].max }
|
|
517
|
+
end
|
|
518
|
+
|
|
336
519
|
def buffered_pools
|
|
337
520
|
buffer_mutex.synchronize { buffer.map { |entry| entry.origin.resolve } }.uniq(&:object_id)
|
|
338
521
|
end
|
|
@@ -348,8 +531,10 @@ module Wurk
|
|
|
348
531
|
# Non-connection failures (OOM, LOADING, READONLY…) must not drop
|
|
349
532
|
# the taken payload — restore it before propagating, or a
|
|
350
533
|
# recovering-but-not-ready Redis silently eats one buffered job per
|
|
351
|
-
# drain tick.
|
|
534
|
+
# drain tick. Redis answered, so the circuit closes and pushes go back
|
|
535
|
+
# to surfacing the refusal to their callers.
|
|
352
536
|
restore(entry)
|
|
537
|
+
circuit.close(client.send(:pool))
|
|
353
538
|
raise
|
|
354
539
|
end
|
|
355
540
|
unless replayed
|
|
@@ -360,6 +545,8 @@ module Wurk
|
|
|
360
545
|
client.send(:emit_enqueued, [entry.payload])
|
|
361
546
|
Wurk::Metrics::Statsd.increment('jobs.recovered.push')
|
|
362
547
|
entry
|
|
548
|
+
ensure
|
|
549
|
+
replay_settled
|
|
363
550
|
end
|
|
364
551
|
|
|
365
552
|
def restore(entry)
|
|
@@ -450,8 +637,10 @@ module Wurk
|
|
|
450
637
|
# racing first pushes different logs.
|
|
451
638
|
@drops = DropLog.new
|
|
452
639
|
|
|
453
|
-
# Background drain thread
|
|
454
|
-
#
|
|
640
|
+
# Background drain thread and the circuit's only prober. Started on the first
|
|
641
|
+
# failed push if the app did not start one; wakes every `interval` seconds and
|
|
642
|
+
# runs `Buffered.drain_all!`, which costs no Redis round trip while the buffer
|
|
643
|
+
# is empty and no circuit is open. drain! already short-circuits on the first
|
|
455
644
|
# transient failure, so a still-down Redis just leaves its entries alone
|
|
456
645
|
# for this tick — no exponential backoff or explicit "reconnect
|
|
457
646
|
# detection" needed; the inner connection retry already lives inside
|
|
@@ -459,6 +648,9 @@ module Wurk
|
|
|
459
648
|
class Drainer
|
|
460
649
|
DEFAULT_INTERVAL = 2.0
|
|
461
650
|
STOP_JOIN_TIMEOUT = 5.0
|
|
651
|
+
# While a circuit is open, pushes are only in memory, so finding out that
|
|
652
|
+
# Redis is back quickly matters more than the interval an idle drainer keeps.
|
|
653
|
+
PROBE_PAUSE = 0.25
|
|
462
654
|
|
|
463
655
|
# Read by `Buffered.reset_after_fork!` off the *inherited* drainer, to
|
|
464
656
|
# rebuild an equivalent one in the child without touching its lock.
|
|
@@ -530,7 +722,8 @@ module Wurk
|
|
|
530
722
|
# Mutex+ConditionVariable lets `stop` wake the thread immediately
|
|
531
723
|
# instead of waiting up to `interval` seconds for sleep to return.
|
|
532
724
|
def wait_interval
|
|
533
|
-
|
|
725
|
+
pause = Buffered.circuit.any_open? ? [@interval, PROBE_PAUSE].min : @interval
|
|
726
|
+
@lock.synchronize { @wake.wait(@lock, pause) unless @done }
|
|
534
727
|
end
|
|
535
728
|
end
|
|
536
729
|
|
|
@@ -540,12 +733,12 @@ module Wurk
|
|
|
540
733
|
# (ConnectionPool::TimeoutError) — and buffers non-batched payloads.
|
|
541
734
|
module InstanceMethods
|
|
542
735
|
def push(item)
|
|
543
|
-
Buffered.drain!(self)
|
|
736
|
+
Buffered.drain!(self) unless Buffered.unbuffered?
|
|
544
737
|
super
|
|
545
738
|
end
|
|
546
739
|
|
|
547
740
|
def push_bulk(items)
|
|
548
|
-
Buffered.drain!(self)
|
|
741
|
+
Buffered.drain!(self) unless Buffered.unbuffered?
|
|
549
742
|
super
|
|
550
743
|
end
|
|
551
744
|
|
|
@@ -555,21 +748,42 @@ module Wurk
|
|
|
555
748
|
# than in Client because this rescue is its only reader: a Client
|
|
556
749
|
# without reliable_push! never allocates it.
|
|
557
750
|
def raw_push(payloads)
|
|
751
|
+
return buffer_without_trying(payloads) if circuit_open_for?(payloads)
|
|
752
|
+
|
|
558
753
|
Thread.current[DELIVERED_KEY] = []
|
|
559
754
|
super
|
|
560
755
|
rescue RedisClient::ConnectionError, ConnectionPool::TimeoutError
|
|
561
|
-
raise if Thread.current[Buffered::DRAINING_KEY]
|
|
756
|
+
raise if Thread.current[Buffered::DRAINING_KEY] || Buffered.unbuffered?
|
|
562
757
|
|
|
758
|
+
buffer_undelivered(payloads)
|
|
759
|
+
ensure
|
|
760
|
+
Thread.current[DELIVERED_KEY] = nil
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Runs inside raw_push's rescue, so a bare `raise` is its connection error.
|
|
764
|
+
# Client#push subtracts the returned payloads from the enqueued metric: they
|
|
765
|
+
# are in the buffer, not in Redis. Whatever the group did deliver stays out
|
|
766
|
+
# of the set and still counts.
|
|
767
|
+
def buffer_undelivered(payloads)
|
|
768
|
+
Buffered.trip!(pool)
|
|
563
769
|
bidless, batched = undelivered(payloads).partition { |p| !p['bid'] }
|
|
564
770
|
enbuffer_bidless(bidless, batched)
|
|
565
771
|
raise unless batched.empty?
|
|
566
772
|
|
|
567
|
-
# Client#push subtracts these from the enqueued metric: they are in
|
|
568
|
-
# the buffer, not in Redis. Whatever the group did deliver stays out
|
|
569
|
-
# of the set and still counts.
|
|
570
773
|
bidless
|
|
571
|
-
|
|
572
|
-
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# Batched payloads never buffer, so they still take the real attempt and
|
|
777
|
+
# report its outcome to the caller.
|
|
778
|
+
def circuit_open_for?(payloads)
|
|
779
|
+
return false if Thread.current[Buffered::DRAINING_KEY] || Buffered.unbuffered?
|
|
780
|
+
|
|
781
|
+
Buffered.circuit.open?(pool) && payloads.none? { |p| p['bid'] }
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def buffer_without_trying(payloads)
|
|
785
|
+
Buffered.enbuffer(payloads, client: self)
|
|
786
|
+
payloads
|
|
573
787
|
end
|
|
574
788
|
|
|
575
789
|
# The payloads this push is not known to have written. A push spanning
|