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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/wurk/api_controller.rb +4 -1
  3. data/lib/wurk/boot_checks.rb +87 -0
  4. data/lib/wurk/capsule.rb +33 -8
  5. data/lib/wurk/cli.rb +73 -8
  6. data/lib/wurk/client/buffered.rb +240 -26
  7. data/lib/wurk/compat.rb +11 -0
  8. data/lib/wurk/configuration.rb +114 -16
  9. data/lib/wurk/cron.rb +45 -0
  10. data/lib/wurk/debounce.rb +2 -2
  11. data/lib/wurk/error_coalescer.rb +35 -0
  12. data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
  13. data/lib/wurk/fetcher/capped.rb +4 -0
  14. data/lib/wurk/fetcher/many_queues.rb +17 -1
  15. data/lib/wurk/fetcher/oom_drain.rb +102 -0
  16. data/lib/wurk/fetcher/orphan_grace.rb +60 -44
  17. data/lib/wurk/fetcher/private_list_key.rb +1 -16
  18. data/lib/wurk/fetcher/reaper.rb +169 -127
  19. data/lib/wurk/fetcher/reliable.rb +26 -4
  20. data/lib/wurk/fetcher/self_reconcile.rb +175 -0
  21. data/lib/wurk/fetcher/unit_of_work.rb +32 -8
  22. data/lib/wurk/health.rb +23 -4
  23. data/lib/wurk/health_probe.rb +64 -0
  24. data/lib/wurk/heartbeat.rb +55 -3
  25. data/lib/wurk/job/options.rb +9 -1
  26. data/lib/wurk/job.rb +2 -24
  27. data/lib/wurk/job_record.rb +17 -9
  28. data/lib/wurk/job_retry.rb +38 -10
  29. data/lib/wurk/job_set.rb +13 -5
  30. data/lib/wurk/launcher.rb +134 -52
  31. data/lib/wurk/leader.rb +9 -3
  32. data/lib/wurk/limiter/base.rb +17 -1
  33. data/lib/wurk/lua/debounce.lua +11 -6
  34. data/lib/wurk/lua/loader.rb +13 -1
  35. data/lib/wurk/lua.rb +71 -8
  36. data/lib/wurk/manager.rb +1 -1
  37. data/lib/wurk/metrics/process_counters.rb +27 -0
  38. data/lib/wurk/metrics/prometheus.rb +35 -14
  39. data/lib/wurk/middleware/current_attributes.rb +26 -10
  40. data/lib/wurk/middleware/poison_pill.rb +1 -2
  41. data/lib/wurk/process_set.rb +3 -1
  42. data/lib/wurk/processor.rb +82 -18
  43. data/lib/wurk/queue_consumers.rb +134 -0
  44. data/lib/wurk/rails_boot.rb +40 -2
  45. data/lib/wurk/rails_reloader.rb +51 -0
  46. data/lib/wurk/railtie.rb +20 -1
  47. data/lib/wurk/redact.rb +12 -0
  48. data/lib/wurk/redis_pool.rb +34 -9
  49. data/lib/wurk/scheduled.rb +142 -67
  50. data/lib/wurk/sentry/death_handler.rb +29 -0
  51. data/lib/wurk/sentry/error_handler.rb +52 -10
  52. data/lib/wurk/sentry/middleware.rb +8 -1
  53. data/lib/wurk/sentry/retry_policy.rb +12 -3
  54. data/lib/wurk/sentry.rb +42 -5
  55. data/lib/wurk/sorted_entry.rb +38 -13
  56. data/lib/wurk/swarm/child_boot.rb +42 -31
  57. data/lib/wurk/swarm/crash_watch.rb +65 -0
  58. data/lib/wurk/swarm.rb +79 -11
  59. data/lib/wurk/testing.rb +66 -0
  60. data/lib/wurk/version.rb +1 -1
  61. data/lib/wurk/web/extension.rb +6 -1
  62. data/lib/wurk/work_set.rb +6 -0
  63. data/lib/wurk/worker/setter.rb +11 -6
  64. data/lib/wurk/worker.rb +17 -1
  65. data/lib/wurk.rb +1 -1
  66. data/vendor/assets/dashboard/assets/{ArgsValue-DDqX6XWH.js → ArgsValue-CmaXArv8.js} +1 -1
  67. data/vendor/assets/dashboard/assets/{BatchDetail-lIytcn2C.js → BatchDetail-nc5yePZW.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{Batches-BEumt-1O.js → Batches-DgEcyOZ_.js} +1 -1
  69. data/vendor/assets/dashboard/assets/{Busy-Vk9HmZcN.js → Busy-CUZncvCy.js} +1 -1
  70. data/vendor/assets/dashboard/assets/{Cron-CWcM-Cfr.js → Cron-CIZbaaZs.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{Dashboard-Bgv1kusv.js → Dashboard-BWmfAook.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Dead-Di5Rct2H.js → Dead-CGKsTZHq.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Extension-LZY5-imc.js → Extension-Doy4bJoA.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{FilterBox-BDo-7IAy.js → FilterBox-C4bO26_d.js} +1 -1
  75. data/vendor/assets/dashboard/assets/{FlowDetail-CehwRLTt.js → FlowDetail-Dh3SJdFh.js} +1 -1
  76. data/vendor/assets/dashboard/assets/{FlowState-glK2Tnf8.js → FlowState-yb7virqR.js} +1 -1
  77. data/vendor/assets/dashboard/assets/{Flows-cgy-Fsbp.js → Flows-C5SMRuhq.js} +1 -1
  78. data/vendor/assets/dashboard/assets/{JobDetailModal-Cuk3jruU.js → JobDetailModal-Cg6sAsJB.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{Limiters-Ci8G2yZG.js → Limiters-BIxwDOJM.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Metrics-CQFUeZTR.js → Metrics-Qp9q0nF8.js} +1 -1
  81. data/vendor/assets/dashboard/assets/{NotFound-CYjhuDsI.js → NotFound-DwjPxFjU.js} +1 -1
  82. data/vendor/assets/dashboard/assets/{PageHeader-DjIAFTyX.js → PageHeader-vWeBbSPF.js} +1 -1
  83. data/vendor/assets/dashboard/assets/{Profiles-DTowqIRg.js → Profiles-7dabYVe3.js} +1 -1
  84. data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
  85. data/vendor/assets/dashboard/assets/{Retries-SJ3BaKC0.js → Retries-CNX8ZtTH.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Scheduled-DxSRpmFZ.js → Scheduled-ClVBj0nc.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Search-bBbi5JNK.js → Search-BAsVE2qw.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{charts-Bg2YBcaD.js → charts-DZs138BV.js} +1 -1
  89. data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
  90. data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
  91. data/vendor/assets/dashboard/assets/{index-BOTVSP9M.js → index-CMzPFxNE.js} +3 -3
  92. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BRPNyV2M.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
  93. data/vendor/assets/dashboard/assets/{useSort-B-BLJik7.js → useSort-jBYVwXvg.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{utils-CGyrKpTh.js → utils-B0kQFNE8.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +41 -30
  98. data/vendor/assets/dashboard/assets/Queues-DUGRPjME.js +0 -1
  99. data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +0 -1
  100. data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +0 -1
@@ -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. The default
415
- # `scheduled_enq` pops then pushes and has a job-loss window
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
- # closing the pop→push job-loss window of the default poller. Swaps the
430
- # pluggable `scheduled_enq` for the atomic promoter; idempotent.
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 ctx — they are the host's own code.
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: a skipped block is never yielded and
796
- # never replayed, and an allowed one yields the configuration and returns
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
- def configure_server
801
- return unless block_given?
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.warn do
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, ZREMs it and
85
- # ZADDs it back, so a pool retry after a lost reply converges on one
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
@@ -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
- return nil unless job
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