wurk 1.9.0 → 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/app/controllers/wurk/api_controller.rb +4 -1
- data/lib/wurk/boot_checks.rb +87 -0
- data/lib/wurk/capsule.rb +33 -8
- data/lib/wurk/cli.rb +73 -8
- data/lib/wurk/client/buffered.rb +240 -26
- data/lib/wurk/compat.rb +11 -0
- data/lib/wurk/configuration.rb +114 -16
- data/lib/wurk/cron.rb +45 -0
- data/lib/wurk/debounce.rb +2 -2
- 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 +1 -16
- data/lib/wurk/fetcher/reaper.rb +169 -127
- 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/health.rb +23 -4
- 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_record.rb +17 -9
- data/lib/wurk/job_retry.rb +38 -10
- data/lib/wurk/job_set.rb +13 -5
- data/lib/wurk/launcher.rb +134 -52
- 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 +71 -8
- data/lib/wurk/manager.rb +1 -1
- data/lib/wurk/metrics/process_counters.rb +27 -0
- data/lib/wurk/metrics/prometheus.rb +35 -14
- data/lib/wurk/middleware/current_attributes.rb +26 -10
- data/lib/wurk/middleware/poison_pill.rb +1 -2
- 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/rails_boot.rb +40 -2
- data/lib/wurk/rails_reloader.rb +51 -0
- data/lib/wurk/railtie.rb +20 -1
- data/lib/wurk/redact.rb +12 -0
- 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/version.rb +1 -1
- data/lib/wurk/web/extension.rb +6 -1
- 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 +1 -1
- data/vendor/assets/dashboard/assets/{ArgsValue-DDqX6XWH.js → ArgsValue-CmaXArv8.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-lIytcn2C.js → BatchDetail-nc5yePZW.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-BEumt-1O.js → Batches-DgEcyOZ_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-Vk9HmZcN.js → Busy-CUZncvCy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-CWcM-Cfr.js → Cron-CIZbaaZs.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dashboard-Bgv1kusv.js → Dashboard-BWmfAook.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dead-Di5Rct2H.js → Dead-CGKsTZHq.js} +1 -1
- data/vendor/assets/dashboard/assets/{Extension-LZY5-imc.js → Extension-Doy4bJoA.js} +1 -1
- data/vendor/assets/dashboard/assets/{FilterBox-BDo-7IAy.js → FilterBox-C4bO26_d.js} +1 -1
- data/vendor/assets/dashboard/assets/{FlowDetail-CehwRLTt.js → FlowDetail-Dh3SJdFh.js} +1 -1
- data/vendor/assets/dashboard/assets/{FlowState-glK2Tnf8.js → FlowState-yb7virqR.js} +1 -1
- data/vendor/assets/dashboard/assets/{Flows-cgy-Fsbp.js → Flows-C5SMRuhq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-Cuk3jruU.js → JobDetailModal-Cg6sAsJB.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Ci8G2yZG.js → Limiters-BIxwDOJM.js} +1 -1
- data/vendor/assets/dashboard/assets/{Metrics-CQFUeZTR.js → Metrics-Qp9q0nF8.js} +1 -1
- data/vendor/assets/dashboard/assets/{NotFound-CYjhuDsI.js → NotFound-DwjPxFjU.js} +1 -1
- data/vendor/assets/dashboard/assets/{PageHeader-DjIAFTyX.js → PageHeader-vWeBbSPF.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-DTowqIRg.js → Profiles-7dabYVe3.js} +1 -1
- data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
- data/vendor/assets/dashboard/assets/{Retries-SJ3BaKC0.js → Retries-CNX8ZtTH.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DxSRpmFZ.js → Scheduled-ClVBj0nc.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-bBbi5JNK.js → Search-BAsVE2qw.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-Bg2YBcaD.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-BOTVSP9M.js → index-CMzPFxNE.js} +3 -3
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BRPNyV2M.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-B-BLJik7.js → useSort-jBYVwXvg.js} +1 -1
- data/vendor/assets/dashboard/assets/{utils-CGyrKpTh.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 -30
- data/vendor/assets/dashboard/assets/Queues-DUGRPjME.js +0 -1
- data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +0 -1
- data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +0 -1
data/lib/wurk/configuration.rb
CHANGED
|
@@ -9,6 +9,8 @@ require_relative 'topology'
|
|
|
9
9
|
require_relative 'cpu_count'
|
|
10
10
|
require_relative 'redis_options'
|
|
11
11
|
require_relative 'keys'
|
|
12
|
+
require_relative 'error_coalescer'
|
|
13
|
+
require_relative 'metrics/process_counters'
|
|
12
14
|
|
|
13
15
|
module Wurk
|
|
14
16
|
# Owns runtime knobs (concurrency, queues, timeouts, lifecycle events,
|
|
@@ -98,7 +100,11 @@ module Wurk
|
|
|
98
100
|
# the fetch path reads this once at boot, so an app that caps nothing
|
|
99
101
|
# resolves to "no capped queues" before the first fetch and never asks
|
|
100
102
|
# again.
|
|
101
|
-
global_concurrency: {}
|
|
103
|
+
global_concurrency: {},
|
|
104
|
+
# See #swarm_crash_exit_after=. Long enough to outlast a slow rollout or a
|
|
105
|
+
# dependency coming back, short enough that a bad release does not sit
|
|
106
|
+
# looking Running for an afternoon.
|
|
107
|
+
swarm_crash_exit_after: 600
|
|
102
108
|
}.freeze
|
|
103
109
|
|
|
104
110
|
# :fork fires only inside swarm children, after fork + internal AR/Redis
|
|
@@ -151,7 +157,7 @@ module Wurk
|
|
|
151
157
|
Wurk::Metrics::Statsd.reset!
|
|
152
158
|
end
|
|
153
159
|
|
|
154
|
-
def initialize(options = {})
|
|
160
|
+
def initialize(options = {}) # rubocop:disable Metrics/AbcSize
|
|
155
161
|
@options = deep_dup_defaults.merge(options)
|
|
156
162
|
# Through the same door `global_concurrency=` uses, so a cap passed to
|
|
157
163
|
# `Configuration.new` is validated rather than trusted, and the default
|
|
@@ -162,14 +168,16 @@ module Wurk
|
|
|
162
168
|
@options[:error_handlers] << ERROR_HANDLER if @options[:error_handlers].empty?
|
|
163
169
|
@capsules = {}
|
|
164
170
|
@directory = {}
|
|
165
|
-
@client_chain = Middleware::Chain.new
|
|
166
|
-
@server_chain = Middleware::Chain.new(on_registration: method(:warn_on_late_server_registration))
|
|
171
|
+
@client_chain = Middleware::Chain.new(self)
|
|
172
|
+
@server_chain = Middleware::Chain.new(self, on_registration: method(:warn_on_late_server_registration))
|
|
167
173
|
@redis_config = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') }
|
|
168
174
|
@web_redis_pool = nil
|
|
169
175
|
@logger = nil
|
|
170
176
|
@thread_priority = DEFAULT_THREAD_PRIORITY
|
|
171
177
|
@telemetry_installed = false
|
|
172
178
|
@frozen = false
|
|
179
|
+
@deferred_server_blocks = []
|
|
180
|
+
@fetch_error_coalescer = ErrorCoalescer.new
|
|
173
181
|
end
|
|
174
182
|
|
|
175
183
|
# --- Hash-like options access -----------------------------------------
|
|
@@ -192,6 +200,7 @@ module Wurk
|
|
|
192
200
|
end
|
|
193
201
|
|
|
194
202
|
def dig(*keys) = @options.dig(*keys)
|
|
203
|
+
def to_json(*) = Wurk.dump_json(@options)
|
|
195
204
|
|
|
196
205
|
# --- Default capsule shortcuts ---------------------------------------
|
|
197
206
|
|
|
@@ -250,6 +259,7 @@ module Wurk
|
|
|
250
259
|
def redis=(hash)
|
|
251
260
|
guard_frozen!
|
|
252
261
|
RedisOptions.validate!(hash)
|
|
262
|
+
warn_on_late_redis_assignment
|
|
253
263
|
@redis_config = @redis_config.merge(hash.transform_keys(&:to_sym))
|
|
254
264
|
end
|
|
255
265
|
|
|
@@ -263,6 +273,11 @@ module Wurk
|
|
|
263
273
|
default_capsule.redis_pool
|
|
264
274
|
end
|
|
265
275
|
|
|
276
|
+
# Parsed INFO, without the pool's own counters RedisPool#info merges in.
|
|
277
|
+
def redis_info
|
|
278
|
+
redis_pool.info.except('size', 'available')
|
|
279
|
+
end
|
|
280
|
+
|
|
266
281
|
# Disconnect and drop every capsule's cached pools (main + fetch) plus the
|
|
267
282
|
# web pool. Used by Wurk::Swarm so the parent never leaks sockets into forks
|
|
268
283
|
# and each child can build fresh ones.
|
|
@@ -272,6 +287,12 @@ module Wurk
|
|
|
272
287
|
@web_redis_pool = nil
|
|
273
288
|
end
|
|
274
289
|
|
|
290
|
+
# Non-terminal counterpart, for a fork in a parent whose threads are live.
|
|
291
|
+
def reload_redis_pools!
|
|
292
|
+
@capsules.each_value(&:reload_redis_pools!)
|
|
293
|
+
@web_redis_pool&.reload!
|
|
294
|
+
end
|
|
295
|
+
|
|
275
296
|
def new_redis_pool(size, name = 'custom')
|
|
276
297
|
build_redis_pool(size: size, name: name)
|
|
277
298
|
end
|
|
@@ -403,6 +424,20 @@ module Wurk
|
|
|
403
424
|
@options[:swarm_heartbeat_timeout]
|
|
404
425
|
end
|
|
405
426
|
|
|
427
|
+
# Seconds every swarm slot may spend crash-looping before the standalone
|
|
428
|
+
# runner reports it and exits non-zero. nil or false keeps respawning forever.
|
|
429
|
+
def swarm_crash_exit_after=(seconds)
|
|
430
|
+
unless seconds.nil? || seconds == false || (seconds.is_a?(Numeric) && seconds.positive?)
|
|
431
|
+
raise ArgumentError, "swarm_crash_exit_after must be nil, false, or a positive number (got #{seconds.inspect})"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
@options[:swarm_crash_exit_after] = seconds || nil
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def swarm_crash_exit_after
|
|
438
|
+
@options[:swarm_crash_exit_after]
|
|
439
|
+
end
|
|
440
|
+
|
|
406
441
|
# --- Reliability (Sidekiq Pro drop-in toggles) -----------------------
|
|
407
442
|
|
|
408
443
|
# Sidekiq Pro's opt-in toggle for reliable fetch. Already the default in
|
|
@@ -411,9 +446,8 @@ module Wurk
|
|
|
411
446
|
# callback. It exists so a Pro initializer drops in unchanged instead of
|
|
412
447
|
# raising NoMethodError.
|
|
413
448
|
#
|
|
414
|
-
# NOTE: `reliable_scheduler!` below is NOT a no-op
|
|
415
|
-
#
|
|
416
|
-
# (Wurk::Scheduled::Enq); only the toggle swaps in the atomic promoter.
|
|
449
|
+
# NOTE: `reliable_scheduler!` below is NOT a no-op: it swaps in the atomic
|
|
450
|
+
# promoter, much faster but skipping client middleware on promotion.
|
|
417
451
|
#
|
|
418
452
|
# The optional block is Pro's recovery callback: `|jobstr, pill|`, fired
|
|
419
453
|
# once per orphan recovery (`pill` nil) and once on a poison kill (`pill`
|
|
@@ -426,8 +460,9 @@ module Wurk
|
|
|
426
460
|
|
|
427
461
|
# Pro reliable scheduler (§4): promote due jobs from retry/schedule onto
|
|
428
462
|
# their target queue in a single atomic Lua (ZRANGEBYSCORE+ZREM+LPUSH),
|
|
429
|
-
#
|
|
430
|
-
# pluggable `scheduled_enq` for the atomic promoter;
|
|
463
|
+
# much faster than the default poller but skipping client middleware on
|
|
464
|
+
# promotion. Swaps the pluggable `scheduled_enq` for the atomic promoter;
|
|
465
|
+
# idempotent.
|
|
431
466
|
def reliable_scheduler!(*)
|
|
432
467
|
self[:scheduled_enq] = Wurk::Scheduled::ReliableEnq
|
|
433
468
|
nil
|
|
@@ -765,6 +800,7 @@ module Wurk
|
|
|
765
800
|
attr_writer :logger
|
|
766
801
|
|
|
767
802
|
def handle_exception(ex, ctx = {})
|
|
803
|
+
ctx = redact_jobstr(ctx)
|
|
768
804
|
if error_handlers.empty?
|
|
769
805
|
logger.error("#{redacted_context(ctx)} #{ex.class}: #{ex.message}")
|
|
770
806
|
else
|
|
@@ -776,11 +812,26 @@ module Wurk
|
|
|
776
812
|
end
|
|
777
813
|
end
|
|
778
814
|
|
|
815
|
+
# What the fetch loop reports through. It sleeps 1s and retries on every
|
|
816
|
+
# thread of every worker, so an outage would otherwise call each handler once
|
|
817
|
+
# per thread per second. The count lands in the metric, the handlers see the
|
|
818
|
+
# first of each exception class and one per window after that.
|
|
819
|
+
def report_fetch_error(exception)
|
|
820
|
+
Metrics::ProcessCounters.increment(:fetch_errors)
|
|
821
|
+
suppressed = @fetch_error_coalescer.admit(exception.class)
|
|
822
|
+
return unless suppressed
|
|
823
|
+
|
|
824
|
+
context = { context: 'Error fetching job' }
|
|
825
|
+
context[:suppressed] = suppressed if suppressed.positive?
|
|
826
|
+
handle_exception(exception, context)
|
|
827
|
+
end
|
|
828
|
+
|
|
779
829
|
# The error context with the job's args passed through the `redact_args`
|
|
780
830
|
# hook, for the two reporters that print or tag the whole context: the
|
|
781
831
|
# fallback log line and the default ERROR_HANDLER (whose Wurk::Context a
|
|
782
832
|
# JSON log formatter emits). Host-registered error handlers still receive
|
|
783
|
-
# the real
|
|
833
|
+
# the real `job` — they are the host's own code (`jobstr` is another matter:
|
|
834
|
+
# see #redact_jobstr).
|
|
784
835
|
def redacted_context(ctx)
|
|
785
836
|
hook = @options[:redact_args]
|
|
786
837
|
job = ctx.is_a?(Hash) ? ctx[:job] : nil
|
|
@@ -790,16 +841,36 @@ module Wurk
|
|
|
790
841
|
ctx.merge(job: job.merge('args' => Wurk::Redact.args(job, hook)))
|
|
791
842
|
end
|
|
792
843
|
|
|
844
|
+
# The raw payload a job that failed to parse or dispatch is reported with. Unlike
|
|
845
|
+
# `job`, this reaches host handlers scrubbed too: it is a string nobody expects
|
|
846
|
+
# to carry the args, so handlers forward it to log aggregators and trackers as
|
|
847
|
+
# is. What it cannot be parsed from cannot be scrubbed, so that is withheld.
|
|
848
|
+
def redact_jobstr(ctx)
|
|
849
|
+
hook = @options[:redact_args]
|
|
850
|
+
raw = ctx.is_a?(Hash) ? ctx[:jobstr] : nil
|
|
851
|
+
return ctx unless hook && raw
|
|
852
|
+
|
|
853
|
+
require_relative 'redact'
|
|
854
|
+
ctx.merge(jobstr: Wurk::Redact.jobstr(raw, hook))
|
|
855
|
+
end
|
|
856
|
+
private :redact_jobstr
|
|
857
|
+
|
|
793
858
|
# --- Configure blocks (Sidekiq.configure_server / _client) -----------
|
|
794
859
|
|
|
795
|
-
# The gate is evaluated per call:
|
|
796
|
-
#
|
|
797
|
-
# the block's value. A skip reports itself once per gate per instance —
|
|
860
|
+
# The gate is evaluated per call: an allowed block yields the configuration
|
|
861
|
+
# and returns the block's value. A skip reports itself once per gate per instance —
|
|
798
862
|
# the flags below decide only whether the skip is reported again, and
|
|
799
863
|
# they live outside @options so a frozen configuration can still warn.
|
|
800
|
-
|
|
801
|
-
|
|
864
|
+
#
|
|
865
|
+
# A skipped block is kept for #enter_server_mode. Gems call this at load
|
|
866
|
+
# time, and under the railtie that is Bundler.require, before the
|
|
867
|
+
# `wurk.server_mode` initializer: dropping it left sidekiq-cron, sentry-sidekiq
|
|
868
|
+
# and the like silently absent from a process that does run workers.
|
|
869
|
+
def configure_server(&block)
|
|
870
|
+
return unless block
|
|
802
871
|
return yield self if server?
|
|
872
|
+
|
|
873
|
+
@deferred_server_blocks << block
|
|
803
874
|
return if @configure_server_skip_warned
|
|
804
875
|
|
|
805
876
|
@configure_server_skip_warned = true
|
|
@@ -807,6 +878,11 @@ module Wurk
|
|
|
807
878
|
nil
|
|
808
879
|
end
|
|
809
880
|
|
|
881
|
+
def enter_server_mode
|
|
882
|
+
self[:server] = true
|
|
883
|
+
@deferred_server_blocks.shift.call(self) until @deferred_server_blocks.empty?
|
|
884
|
+
end
|
|
885
|
+
|
|
810
886
|
# A web process that also boots the swarm (Wurk::RailsBoot) is flagged
|
|
811
887
|
# server, yet every enqueue it makes from a request is client work, so
|
|
812
888
|
# `client_in_server` lets its configure_client blocks run too. Same
|
|
@@ -896,6 +972,16 @@ module Wurk
|
|
|
896
972
|
self
|
|
897
973
|
end
|
|
898
974
|
|
|
975
|
+
# A swarm child inherits the parent's prepare_for_fork! freeze, but its copy
|
|
976
|
+
# is its own, and Launcher#run freezes it again. In between it is what a
|
|
977
|
+
# non-swarm process is: open to `:fork` / `:startup` hooks and to gems that
|
|
978
|
+
# prepend Launcher#initialize and write an option, as sidekiq-cron does.
|
|
979
|
+
def reopen_in_forked_child
|
|
980
|
+
@options = @options.dup
|
|
981
|
+
@frozen = false
|
|
982
|
+
self
|
|
983
|
+
end
|
|
984
|
+
|
|
899
985
|
def frozen?
|
|
900
986
|
@frozen
|
|
901
987
|
end
|
|
@@ -906,6 +992,18 @@ module Wurk
|
|
|
906
992
|
|
|
907
993
|
private
|
|
908
994
|
|
|
995
|
+
# Pools read the hash once, when built, so the later assignment silently does
|
|
996
|
+
# nothing to this process's connections until something rebuilds them.
|
|
997
|
+
def warn_on_late_redis_assignment
|
|
998
|
+
return unless @capsules.each_value.any?(&:redis_pools_built?)
|
|
999
|
+
|
|
1000
|
+
logger.warn do
|
|
1001
|
+
'config.redis= called after a Redis pool was already built; existing connections keep the old ' \
|
|
1002
|
+
'settings until the pools are rebuilt (a swarm child rebuilds them, a single process does not). ' \
|
|
1003
|
+
'Set config.redis before anything uses Redis.'
|
|
1004
|
+
end
|
|
1005
|
+
end
|
|
1006
|
+
|
|
909
1007
|
def warn_on_late_server_registration(klass)
|
|
910
1008
|
affected = @capsules.each_value.filter_map do |capsule|
|
|
911
1009
|
capsule.name if capsule.prepared? && capsule.server_middleware_missing?(klass)
|
|
@@ -1023,7 +1121,7 @@ module Wurk
|
|
|
1023
1121
|
threads = total_concurrency
|
|
1024
1122
|
return if size >= threads
|
|
1025
1123
|
|
|
1026
|
-
logger.
|
|
1124
|
+
logger.error do
|
|
1027
1125
|
"ActiveRecord connection pool size (#{size}) is smaller than this process's job concurrency " \
|
|
1028
1126
|
"(#{threads}); jobs that use the database will wait for connections and may raise " \
|
|
1029
1127
|
'ActiveRecord::ConnectionTimeoutError. Raise `pool:` in config/database.yml to at least ' \
|
data/lib/wurk/cron.rb
CHANGED
|
@@ -461,8 +461,53 @@ module Wurk
|
|
|
461
461
|
loop_obj
|
|
462
462
|
end
|
|
463
463
|
|
|
464
|
+
# Registers a sidekiq-cron shaped schedule: `{ name => { cron:, class:,
|
|
465
|
+
# queue:, args:, retry:, tz:, status: } }`, or a list of those carrying
|
|
466
|
+
# `name:`. Other keys (`description`, ...) are ignored. Raises ArgumentError
|
|
467
|
+
# on an entry with no cron or class, and on anything but a 5-field crontab.
|
|
468
|
+
def load_hash(schedule)
|
|
469
|
+
entries = schedule
|
|
470
|
+
entries = schedule.map { |name, entry| { 'name' => name }.merge(entry.to_h) } if schedule.is_a?(::Hash)
|
|
471
|
+
entries.map { |entry| register_entry(entry.to_h.transform_keys(&:to_s)) }
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# The same shape from a YAML file (ERB allowed), like a `config/schedule.yml`.
|
|
475
|
+
def load_file(path)
|
|
476
|
+
require 'erb'
|
|
477
|
+
require 'yaml'
|
|
478
|
+
load_hash(::YAML.safe_load(::ERB.new(::File.read(path)).result, aliases: true) || {})
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Call after the last registration to make this block the whole truth:
|
|
482
|
+
# every other loop in the cluster is deleted. Off unless asked because a
|
|
483
|
+
# process registering only part of the schedule would delete the rest, and
|
|
484
|
+
# because during a rolling deploy old and new code then disagree.
|
|
485
|
+
def authoritative!
|
|
486
|
+
return self unless @prune
|
|
487
|
+
|
|
488
|
+
keep = @loops.map(&:lid)
|
|
489
|
+
LoopSet.new(@config).each { |existing| Cron.unregister(existing.lid) unless keep.include?(existing.lid) }
|
|
490
|
+
self
|
|
491
|
+
end
|
|
492
|
+
|
|
464
493
|
private
|
|
465
494
|
|
|
495
|
+
def register_entry(entry)
|
|
496
|
+
cron = entry['cron'].to_s.strip
|
|
497
|
+
klass = (entry['class'] || entry['klass']).to_s
|
|
498
|
+
if cron.empty? || klass.empty?
|
|
499
|
+
raise ArgumentError, "schedule entry #{entry['name'].inspect} needs `cron` and `class`"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
register(cron, klass, **entry_options(entry))
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def entry_options(entry)
|
|
506
|
+
opts = { label: entry['name']&.to_s, queue: entry['queue'], args: entry['args'], retry: entry['retry'],
|
|
507
|
+
tz: entry['tz'], paused: entry['status'] == 'disabled' ? true : nil }
|
|
508
|
+
opts.compact
|
|
509
|
+
end
|
|
510
|
+
|
|
466
511
|
# Once per class per boot: everything else registered for that class is
|
|
467
512
|
# a leftover from a previous deploy (the lid hashes schedule+options, so
|
|
468
513
|
# an edited `register` line yields a new loop and orphans the old one).
|
data/lib/wurk/debounce.rb
CHANGED
|
@@ -81,8 +81,8 @@ module Wurk
|
|
|
81
81
|
reject_shorter_cap!(seconds, cap)
|
|
82
82
|
|
|
83
83
|
argv = [seconds, cap, JobUtil.scheduled_member(job), GRACE]
|
|
84
|
-
# Replay-safe: a re-run finds the member it just wrote
|
|
85
|
-
#
|
|
84
|
+
# Replay-safe: a re-run finds the member it just wrote and ZADDs it
|
|
85
|
+
# over itself, so a pool retry after a lost reply converges on one
|
|
86
86
|
# entry rather than a duplicate — the whole point of doing this in a
|
|
87
87
|
# script rather than a pipeline.
|
|
88
88
|
#
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Lets the first report of a key through, then one per window, and says how
|
|
5
|
+
# many it swallowed in between. Per process: every forked child speaks for
|
|
6
|
+
# itself, so a fleet still reports at worst children x keys per window.
|
|
7
|
+
class ErrorCoalescer
|
|
8
|
+
DEFAULT_WINDOW = 60
|
|
9
|
+
|
|
10
|
+
def initialize(window: DEFAULT_WINDOW, clock: -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) })
|
|
11
|
+
@window = window
|
|
12
|
+
@clock = clock
|
|
13
|
+
@lock = ::Mutex.new
|
|
14
|
+
@entries = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The number of reports suppressed since the last one when this one should
|
|
18
|
+
# go out (0 for a first report); nil when this one should be swallowed.
|
|
19
|
+
def admit(key)
|
|
20
|
+
@lock.synchronize do
|
|
21
|
+
now = @clock.call
|
|
22
|
+
entry = (@entries[key] ||= [nil, 0])
|
|
23
|
+
if entry[0] && now - entry[0] < @window
|
|
24
|
+
entry[1] += 1
|
|
25
|
+
next nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
suppressed = entry[1]
|
|
29
|
+
entry[0] = now
|
|
30
|
+
entry[1] = 0
|
|
31
|
+
suppressed
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'oom_drain'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
class Fetcher
|
|
7
|
+
# {Fetcher::Reliable}'s fetch once Redis has refused one with OOM: a full
|
|
8
|
+
# Redis refuses LMOVE, which would stop every worker draining the queues that
|
|
9
|
+
# filled it. See OomDrain for what makes this possible. Only reached after an
|
|
10
|
+
# OutOfMemoryError, so it costs a healthy fetch nothing.
|
|
11
|
+
module AtMaxmemory
|
|
12
|
+
# Seconds between error reports while draining a full Redis. Every thread
|
|
13
|
+
# lands here on every fetch for as long as it lasts.
|
|
14
|
+
REPORT_INTERVAL = 60
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Costs the wall clock a BLMOVE would have on an empty pass, for the reason
|
|
19
|
+
# #retrieve_work's short-circuits do. An ACK is an LREM, which a full Redis
|
|
20
|
+
# still takes.
|
|
21
|
+
def claim_at_maxmemory(queues, error)
|
|
22
|
+
flush_pending_acks
|
|
23
|
+
report_maxmemory(error)
|
|
24
|
+
fetchable = fetchable_at_maxmemory(queues)
|
|
25
|
+
raise error if fetchable.empty?
|
|
26
|
+
|
|
27
|
+
index, job = claim_past_maxmemory(fetchable, error)
|
|
28
|
+
return unit_at_maxmemory(fetchable[index - 1], job) if job
|
|
29
|
+
|
|
30
|
+
sleep poll_interval
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A pipeline applies everything it can before raising; the guard makes what
|
|
35
|
+
# already went back a no-op here.
|
|
36
|
+
def requeue_past_maxmemory(units, error)
|
|
37
|
+
config.redis(idempotent: true) do |conn|
|
|
38
|
+
units.each { |uow| OomDrain.requeue(conn, error, queue_keys(uow.queue).first, uow.queue, uow.job) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def claim_past_maxmemory(fetchable, error)
|
|
43
|
+
keys = fetchable.flat_map { |public_q| [public_q, queue_keys(public_q).first] }
|
|
44
|
+
config.redis(idempotent: true) do |conn|
|
|
45
|
+
OomDrain.claim(conn, error, keys: keys, info: maxmemory_info, beat: ::Time.now.to_f.to_s)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unit_at_maxmemory(public_q, job)
|
|
50
|
+
priv, name = queue_keys(public_q)
|
|
51
|
+
unit_of_work(public_q, priv, name, job)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Capped overrides this: a cap cannot be honoured by a script that skips
|
|
55
|
+
# the ledger's write.
|
|
56
|
+
def fetchable_at_maxmemory(queues)
|
|
57
|
+
queues
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# What the claim script writes if this process has no heartbeat hash (it
|
|
61
|
+
# booted into the full Redis). The first ordinary beat replaces it.
|
|
62
|
+
def maxmemory_info
|
|
63
|
+
Wurk.dump_json(
|
|
64
|
+
'hostname' => hostname, 'pid' => ::Process.pid, 'identity' => Component.identity,
|
|
65
|
+
'started_at' => ::Time.now.to_f, 'tag' => '', 'labels' => [], 'concurrency' => config.concurrency,
|
|
66
|
+
'queues' => config.queues.uniq, 'version' => Wurk::VERSION, 'embedded' => false
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def report_maxmemory(error)
|
|
71
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
72
|
+
return if @oom_reported_at && now - @oom_reported_at < REPORT_INTERVAL
|
|
73
|
+
|
|
74
|
+
@oom_reported_at = now
|
|
75
|
+
handle_exception(error, { context: 'Redis is at maxmemory: draining with allow-oom scripts' })
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/wurk/fetcher/capped.rb
CHANGED
|
@@ -210,6 +210,10 @@ module Wurk
|
|
|
210
210
|
blocked ? [poll_interval, CAPPED_BACKOFF].min : poll_interval
|
|
211
211
|
end
|
|
212
212
|
|
|
213
|
+
def fetchable_at_maxmemory(queues)
|
|
214
|
+
queues.select { |public_q| gate_for(public_q).nil? }
|
|
215
|
+
end
|
|
216
|
+
|
|
213
217
|
def gate_for(public_q)
|
|
214
218
|
@gates[queue_keys(public_q)[1]]
|
|
215
219
|
end
|
|
@@ -23,6 +23,16 @@ module Wurk
|
|
|
23
23
|
module ManyQueues
|
|
24
24
|
NO_ARGV = [].freeze
|
|
25
25
|
|
|
26
|
+
# How long a script pass that found every queue empty answers for the whole
|
|
27
|
+
# fetcher. The script sends two keys per queue, per thread, per poll: 120 KB
|
|
28
|
+
# a pass at 1000 queues, which at concurrency 500 cost a process 70% of a
|
|
29
|
+
# core and Redis 26% of one while idle. A thread skipping it parks in BLMOVE
|
|
30
|
+
# on the first queue for the whole poll, unaware of anything that lands on a
|
|
31
|
+
# later queue meanwhile, so a job can wait out a poll interval (about 2s)
|
|
32
|
+
# with a thread idle. That is the same bound an empty script pass gives it.
|
|
33
|
+
# Not a config knob.
|
|
34
|
+
REST_MISS_BACKOFF = 0.25
|
|
35
|
+
|
|
26
36
|
private
|
|
27
37
|
|
|
28
38
|
# Queues 2..n. Two queues is one more LMOVE — a one-key script costs more
|
|
@@ -34,11 +44,17 @@ module Wurk
|
|
|
34
44
|
def claim_rest(queues)
|
|
35
45
|
return lmove(queues[1]) if queues.size == 2
|
|
36
46
|
|
|
47
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
48
|
+
return nil if now < @rest_empty_until
|
|
49
|
+
|
|
37
50
|
keys = claim_keys(queues)
|
|
38
51
|
index, job = config.redis(idempotent: true) do |conn|
|
|
39
52
|
Wurk::Lua::Loader.eval_cached(conn, :fetch_first, keys: keys, argv: NO_ARGV)
|
|
40
53
|
end
|
|
41
|
-
|
|
54
|
+
unless job
|
|
55
|
+
@rest_empty_until = now + REST_MISS_BACKOFF
|
|
56
|
+
return nil
|
|
57
|
+
end
|
|
42
58
|
|
|
43
59
|
public_q = queues[index]
|
|
44
60
|
priv, name = queue_keys(public_q)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../keys'
|
|
4
|
+
require_relative '../lua'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
class Fetcher
|
|
8
|
+
# The way out of a full Redis.
|
|
9
|
+
#
|
|
10
|
+
# LMOVE and ZADD are `denyoom` commands, so at `maxmemory` under the
|
|
11
|
+
# `noeviction` policy Wurk requires, every worker would be refused its next
|
|
12
|
+
# job and could therefore never free the memory that is refusing it. A
|
|
13
|
+
# Redis 7 script flagged `allow-oom` runs those commands anyway, and moving a
|
|
14
|
+
# job between two lists grows nothing but a small key.
|
|
15
|
+
#
|
|
16
|
+
# Deliberately not under lib/wurk/lua/: everything there is uploaded at boot,
|
|
17
|
+
# and Dragonfly refuses to compile a `#!lua` shebang, so one script in that
|
|
18
|
+
# directory would crash every child at boot. The same refusal is why these
|
|
19
|
+
# go by EVAL and not EVALSHA: a path taken only while Redis is full has no
|
|
20
|
+
# use for a NOSCRIPT recovery arm. A server that refuses the script raises
|
|
21
|
+
# the original OOM, which is exactly the behaviour without this class.
|
|
22
|
+
module OomDrain
|
|
23
|
+
HEADER = "#!lua flags=allow-oom\n"
|
|
24
|
+
|
|
25
|
+
# Heartbeat::TTL_SECONDS. Spelled out because the fetcher loads without the
|
|
26
|
+
# heartbeat, which pulls in the Processor.
|
|
27
|
+
HEARTBEAT_TTL = 60
|
|
28
|
+
|
|
29
|
+
# Claiming proves this process alive, and it has to say so itself: the
|
|
30
|
+
# heartbeat's own HSET is refused too. The hash then lapses (a booting
|
|
31
|
+
# process never gets one), other hosts' reapers drain the jobs it is
|
|
32
|
+
# running, and the swarm's liveness check sees its beat stand still.
|
|
33
|
+
#
|
|
34
|
+
# KEYS = [processes, identity, public, private, public, private, ...]
|
|
35
|
+
# ARGV = [info_json, beat, ttl_seconds]
|
|
36
|
+
# Returns {n, payload} for the n-th queue, nil when all were empty.
|
|
37
|
+
CLAIM = <<~LUA.freeze
|
|
38
|
+
#{HEADER}if redis.call('EXISTS', KEYS[2]) == 0 then
|
|
39
|
+
redis.call('HSET', KEYS[2], 'info', ARGV[1])
|
|
40
|
+
redis.call('SADD', KEYS[1], KEYS[2])
|
|
41
|
+
end
|
|
42
|
+
redis.call('HSET', KEYS[2], 'beat', ARGV[2])
|
|
43
|
+
redis.call('EXPIRE', KEYS[2], ARGV[3])
|
|
44
|
+
for i = 3, #KEYS, 2 do
|
|
45
|
+
local job = redis.call('LMOVE', KEYS[i], KEYS[i + 1], 'RIGHT', 'LEFT')
|
|
46
|
+
if job then
|
|
47
|
+
return { (i - 1) / 2, job }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
return nil
|
|
51
|
+
LUA
|
|
52
|
+
|
|
53
|
+
RECLAIM = (HEADER + Lua::RELIABLE_RECLAIM).freeze
|
|
54
|
+
REQUEUE = (HEADER + Lua::RELIABLE_REQUEUE).freeze
|
|
55
|
+
|
|
56
|
+
# KEYS = [lock], ARGV = [ttl_ms]. The reaper's sweep lock: refused, no sweep
|
|
57
|
+
# would ever run past maxmemory. Same reply as the SET it stands in for.
|
|
58
|
+
LOCK = <<~LUA.freeze
|
|
59
|
+
#{HEADER}return redis.call('SET', KEYS[1], '1', 'NX', 'PX', ARGV[1])
|
|
60
|
+
LUA
|
|
61
|
+
|
|
62
|
+
# KEYS = [sorted_set], ARGV = [score, member]
|
|
63
|
+
ZADD = <<~LUA.freeze
|
|
64
|
+
#{HEADER}return redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
|
|
65
|
+
LUA
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# `error` is the OOM that sent the caller here, raised again if the server
|
|
70
|
+
# cannot run the script. Returns [queue_index, payload] or nil.
|
|
71
|
+
def claim(conn, error, keys:, info:, beat:)
|
|
72
|
+
run(conn, error, CLAIM, [Keys::PROCESSES, Component.identity, *keys], [info, beat, HEARTBEAT_TTL])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reclaim(conn, error, source, destination)
|
|
76
|
+
run(conn, error, RECLAIM, [source, destination], [])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns 1 when moved, 0 when the job has left the private list since.
|
|
80
|
+
def requeue(conn, error, private_list, public_q, job)
|
|
81
|
+
run(conn, error, REQUEUE, [private_list, public_q], [job])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def lock(conn, error, key, ttl_ms)
|
|
85
|
+
run(conn, error, LOCK, [key], [ttl_ms])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def zadd(conn, error, key, score, member)
|
|
89
|
+
run(conn, error, ZADD, [key], [score, member])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def run(conn, error, script, keys, argv)
|
|
93
|
+
conn.call('EVAL', script, keys.size, *keys, *argv)
|
|
94
|
+
rescue RedisClient::ConnectionError
|
|
95
|
+
raise
|
|
96
|
+
rescue RedisClient::CommandError
|
|
97
|
+
raise error
|
|
98
|
+
end
|
|
99
|
+
private_class_method :run
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|