wurk 1.10.0 → 1.11.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.
data/lib/wurk/cron.rb CHANGED
@@ -450,6 +450,21 @@ module Wurk
450
450
  @pruned_klasses = {}
451
451
  end
452
452
 
453
+ # How late a slot may fire, in seconds, instead of being skipped, for every
454
+ # loop of this cluster's pollers. Default MISSED_TICK_THRESHOLD. A process-wide
455
+ # setting and not a loop option, which would enter the loop's identity.
456
+ #
457
+ # Coerced here, because the poller rescues per loop: a value it could not
458
+ # compare would stop every loop on every tick with nothing failing at boot.
459
+ def grace=(seconds)
460
+ grace = Float(seconds, exception: false)
461
+ unless grace&.finite? && grace.positive?
462
+ raise ArgumentError, "periodic grace must be a positive, finite number of seconds: #{seconds.inspect}"
463
+ end
464
+
465
+ (@config || Wurk.configuration)[:cron_grace] = grace
466
+ end
467
+
453
468
  def register(cron, klass, **opts)
454
469
  klass_name = klass.is_a?(String) ? klass : klass.to_s
455
470
  tz = opts.delete(:tz) || @tz
@@ -611,10 +626,12 @@ module Wurk
611
626
  end
612
627
  end
613
628
 
614
- # Once-per-minute tick driver. Only the cluster leader iterates the LoopSet
615
- # and enqueues; non-leaders return early. A slot that wall-clock has passed
616
- # by more than `MISSED_TICK_THRESHOLD` seconds is skipped with a warning,
617
- # never backfilled.
629
+ # Once-per-minute tick driver, plus one tick the moment this process wins the
630
+ # lock: at a tick's phase, a leader that lives under a minute would never tick.
631
+ # Only the cluster leader iterates the LoopSet and enqueues; non-leaders return
632
+ # early. A slot that wall-clock has passed by more than `MISSED_TICK_THRESHOLD`
633
+ # seconds is skipped with a warning, never backfilled, and the occurrence due
634
+ # now, if any, fires in its place.
618
635
  class Poller
619
636
  include Component
620
637
 
@@ -625,17 +642,22 @@ module Wurk
625
642
  # Operators never need to touch this; integration tests shrink it so a
626
643
  # due loop fires within the test window instead of waiting a full minute.
627
644
  @tick_interval = config[:cron_tick_interval] || DEFAULT_TICK_SECONDS
628
- @timer = TimerLoop.new(@tick_interval)
645
+ @done = false
646
+ @woken = false
647
+ @mutex = ::Mutex.new
648
+ @sleeper = ::ConditionVariable.new
649
+ @on_leader = method(:wake_for_leadership)
629
650
  end
630
651
 
631
- # TimerLoop waits one interval before the first tick: don't fire a
632
- # catch-up burst the instant we boot (the leader is barely settled), and
633
- # let a short-lived process exit without ticking at all.
652
+ # The loop waits one interval before its first tick unless this process
653
+ # wins the lock first: let a process that never leads, or exits at once,
654
+ # exit without ticking at all.
634
655
  def start
635
656
  return @thread if @thread
636
657
 
637
- @timer.reset
638
- @thread = safe_thread('cron-poller') { @timer.run { tick } }
658
+ @mutex.synchronize { @done = false }
659
+ leader_hooks << @on_leader
660
+ @thread = safe_thread('cron-poller') { run_loop }
639
661
  end
640
662
 
641
663
  # Blocks until the thread is really gone: the launcher releases the
@@ -644,10 +666,14 @@ module Wurk
644
666
  #
645
667
  # Cleared only on a confirmed join (Thread#join returns nil on timeout):
646
668
  # a wedged thread must stay tracked so #start's guard returns it instead
647
- # of calling @timer.reset, which would un-terminate the loop it is still
648
- # inside and leave two tick threads double-enqueuing the same loops.
669
+ # of clearing @done, which would un-terminate the loop it is still inside
670
+ # and leave two tick threads double-enqueuing the same loops.
649
671
  def terminate
650
- @timer.terminate
672
+ leader_hooks.delete(@on_leader)
673
+ @mutex.synchronize do
674
+ @done = true
675
+ @sleeper.signal
676
+ end
651
677
  @thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
652
678
  end
653
679
 
@@ -679,6 +705,10 @@ module Wurk
679
705
  # A slot older than the missed-tick threshold is not backfilled (ent §2.6:
680
706
  # no backfill on restart, missed ticks across a leader gap are dropped):
681
707
  # the marks still advance past it, but nothing is pushed.
708
+ #
709
+ # One claim, one push at most, whichever slot it pushes for: the occurrence
710
+ # due now stands in for a stale one under the stale one's mark, so a poller
711
+ # that read the same mark loses the CAS for both.
682
712
  def enqueue_if_due(loop_obj)
683
713
  return if loop_obj.paused?
684
714
 
@@ -686,9 +716,12 @@ module Wurk
686
716
  slot, mark = due_slot(loop_obj, now)
687
717
  return if slot.nil?
688
718
 
689
- future = loop_obj.next_fire_after(slot, now)
719
+ firing = slot_to_fire(loop_obj, slot, now)
720
+ future = loop_obj.next_fire_after(firing || slot, now)
690
721
  return unless claim_fire?(loop_obj, mark, now, future)
691
- return skip_missed(loop_obj, slot, now) if now - slot > missed_threshold
722
+
723
+ skip_missed(loop_obj, slot, now) unless firing == slot
724
+ return if firing.nil?
692
725
 
693
726
  jid = enqueue_claimed!(loop_obj)
694
727
  record_history(loop_obj, jid, now)
@@ -708,6 +741,53 @@ module Wurk
708
741
 
709
742
  private
710
743
 
744
+ def run_loop
745
+ wait_for_tick
746
+ until done?
747
+ tick
748
+ wait_for_tick
749
+ end
750
+ end
751
+
752
+ def wait_for_tick
753
+ @mutex.synchronize do
754
+ @sleeper.wait(@mutex, @tick_interval) unless @done || @woken
755
+ @woken = false
756
+ end
757
+ end
758
+
759
+ def done?
760
+ @mutex.synchronize { @done }
761
+ end
762
+
763
+ # Runs on the campaign thread. The gate's cached answer predates the win.
764
+ def wake_for_leadership
765
+ @leader_checked_at = nil
766
+ @mutex.synchronize do
767
+ @woken = true
768
+ @sleeper.signal
769
+ end
770
+ end
771
+
772
+ def leader_hooks
773
+ @config[:lifecycle_events].fetch(:leader)
774
+ end
775
+
776
+ def slot_to_fire(loop_obj, slot, now)
777
+ now - slot > missed_threshold ? latest_due(loop_obj, now) : slot
778
+ end
779
+
780
+ # The newest occurrence still inside the window a late slot may fire in.
781
+ def latest_due(loop_obj, now)
782
+ latest = nil
783
+ occurrence = loop_obj.next_fire_at(now - missed_threshold - 1)
784
+ while occurrence && occurrence <= now
785
+ latest = occurrence
786
+ occurrence = loop_obj.next_fire_at(occurrence)
787
+ end
788
+ latest
789
+ end
790
+
711
791
  # The slot this tick would fire and the token that claims it, or nil when
712
792
  # the loop has no resolvable occurrence or none is due yet. The token is
713
793
  # the stored `nf` when there is one, otherwise the slot we derived — see
@@ -723,7 +803,7 @@ module Wurk
723
803
  # Never tighter than a tick and a half: a raised tick interval must not
724
804
  # turn every on-time fire into a "missed" one.
725
805
  def missed_threshold
726
- [MISSED_TICK_THRESHOLD, @tick_interval * 1.5].max
806
+ [@config[:cron_grace] || MISSED_TICK_THRESHOLD, @tick_interval * 1.5].max
727
807
  end
728
808
 
729
809
  def skip_missed(loop_obj, expected, now)
data/lib/wurk/embedded.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'component'
4
4
  require_relative 'launcher'
5
+ require_relative 'swarm/test_mode_guard'
5
6
 
6
7
  module Wurk
7
8
  # Embeds Wurk inside an arbitrary Ruby process (Puma, rake task, custom
@@ -34,13 +35,13 @@ module Wurk
34
35
  # the worker threads have time to spin up before the host goes back to
35
36
  # serving traffic. Mirrors Sidekiq::Embedded#run.
36
37
  def run
38
+ Swarm::TestModeGuard.enforce!(logger)
37
39
  housekeeping
38
40
  fire_event(:startup, reverse: false, reraise: true)
39
41
  @launcher = build_launcher
40
42
  @launcher.run
41
43
  sleep 0.2
42
- logger.info { "Wurk running embedded, total process thread count: #{Thread.list.size}" }
43
- logger.debug { Thread.list.map(&:name).to_s }
44
+ log_running
44
45
  rescue StandardError
45
46
  # The host is left believing Wurk never started, so anything the launcher
46
47
  # did get up would keep fetching, beating and campaigning for the leader
@@ -62,6 +63,11 @@ module Wurk
62
63
 
63
64
  private
64
65
 
66
+ def log_running
67
+ logger.info { "Wurk running embedded, total process thread count: #{Thread.list.size}" }
68
+ logger.debug { Thread.list.map(&:name).to_s }
69
+ end
70
+
65
71
  # Guarded so the caller sees why the boot failed, not why the rollback did.
66
72
  def roll_back_partial_boot
67
73
  stop
@@ -83,6 +83,12 @@ module Wurk
83
83
  # is what callers see as the dead record's `error_class`.
84
84
  class DecryptionError < Error; end
85
85
 
86
+ # The job says `encrypt: true` but its last argument is not a Wurk envelope:
87
+ # another implementation's ciphertext, or a producer that never enabled
88
+ # crypto. Neither can be told from a plain value, and either way `perform`
89
+ # must not take it for the decrypted argument.
90
+ class NotSealedError < Error; end
91
+
86
92
  class << self
87
93
  attr_reader :active_version
88
94
 
@@ -228,7 +234,10 @@ module Wurk
228
234
  'failed_at' => ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
229
235
  )
230
236
  Wurk::Metrics::Statsd.increment('jobs.encryption_error', tags: ["worker:#{job['class']}"])
231
- Wurk::DeadSet.new.kill(Wurk.dump_json(record), ex: cause)
237
+ Wurk.logger.error { "#{DEAD_REASON}: #{job['class']} jid=#{job['jid']} sent to the dead set: #{cause.message}" }
238
+ # No trim: a misconfigured producer or a lost key can fail a whole backlog at
239
+ # once, and the cap would destroy jobs that are recoverable once it is fixed.
240
+ Wurk::DeadSet.new.kill(Wurk.dump_json(record), ex: cause, trim: false)
232
241
  nil
233
242
  end
234
243
 
@@ -318,14 +327,19 @@ module Wurk
318
327
  # with every outer middleware and the processor; leaving plaintext in it
319
328
  # let any of them write the secret back to Redis (InterruptHandler's
320
329
  # re-push, limiter dead-routing) or hand it to an error reporter. A decrypt
321
- # failure (missing/rotated key, bad tag) is terminal and non-retryable,
322
- # so rather than let it bubble into the 25× retry pipeline we route the
330
+ # failure (missing/rotated key, bad tag, a last argument that is no envelope)
331
+ # is terminal and non-retryable, so rather than let it bubble into the 25× retry pipeline we route the
323
332
  # job straight to the dead set tagged `encryption_error` and ACK it via
324
333
  # JobRetry::Skip — see Wurk::Encryption.route_to_dead. Plaintext args
325
334
  # remain visible for triage per §4.6.
326
335
  class ServerMiddleware
327
336
  include Wurk::Middleware::ServerMiddleware
328
337
 
338
+ # Never quotes the argument: it may be a secret in the clear.
339
+ NOT_SEALED_MESSAGE = 'job has encrypt: true but its last argument is not a Wurk envelope, so it was not run. ' \
340
+ 'A producer without crypto enabled pushed it, or another implementation encrypted it. ' \
341
+ 'A retry re-seals the argument as it stands: retry only a job known to hold plaintext'
342
+
329
343
  def call(_worker, job, _queue)
330
344
  return yield unless Wurk::Encryption.enabled? && job['encrypt']
331
345
 
@@ -342,7 +356,8 @@ module Wurk
342
356
 
343
357
  def decrypt_last_arg!(job)
344
358
  args = job['args']
345
- return unless args.is_a?(::Array) && !args.empty? && Wurk::Encryption.envelope?(args.last)
359
+ return unless args.is_a?(::Array) && !args.empty?
360
+ raise NotSealedError, NOT_SEALED_MESSAGE unless Wurk::Encryption.envelope?(args.last)
346
361
 
347
362
  job['args'] = args[0..-2] + [Wurk::Encryption.decrypt(args.last)]
348
363
  rescue Wurk::Encryption::Error,
@@ -350,7 +365,7 @@ module Wurk
350
365
  ::ArgumentError,
351
366
  ::TypeError,
352
367
  ::JSON::ParserError => e
353
- # Wurk::Encryption::Error covers KeyMissingError / DecryptionError.
368
+ # Wurk::Encryption::Error covers KeyMissingError / DecryptionError / NotSealedError.
354
369
  # OpenSSL::Cipher::CipherError → tampered ciphertext / bad key (tag mismatch).
355
370
  # ArgumentError → Base64.strict_decode64 / Integer() on a malformed envelope.
356
371
  # TypeError → Integer(nil) on a missing 'v' field.
data/lib/wurk/leader.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'securerandom'
4
4
  require 'socket'
5
5
  require_relative 'component'
6
+ require_relative 'fetcher/oom_drain'
6
7
  require_relative 'lua'
7
8
  require_relative 'pool_checkout'
8
9
  require_relative 'timer_loop'
@@ -21,8 +22,12 @@ module Wurk
21
22
  # *can* benefit from a guard has one available; the token is best-effort
22
23
  # too — it is never re-read on subsequent acquires, only on transitions.
23
24
  #
24
- # Cadence per spec: renew every 15s while leader, recheck every 60s as
25
- # follower, lock TTL 30s. The campaign itself starts `initial_wait` seconds
25
+ # Cadence per spec: renew every 15s while leader, lock TTL 30s. A follower
26
+ # rechecks when the lease it last read runs out, at most 60s later, and that
27
+ # is what bounds the leaderless time after a SIGKILL: the lease's remaining
28
+ # 15-30s and about a second. Waiting out a flat 60s added up to 60s more. The
29
+ # lock itself is untouched, so a follower still cannot win before the lease
30
+ # has lapsed. The campaign itself starts `initial_wait` seconds
26
31
  # after `#start` — the spec fixes the renew/follower/TTL cadence but says
27
32
  # nothing about when the first campaign runs, and holding it back keeps a
28
33
  # booting process's opening Redis round trips on the job path. Opt out a
@@ -43,6 +48,17 @@ module Wurk
43
48
  # nothing observes the gap. Tune with `config[:leader_initial_wait]`;
44
49
  # 0 campaigns immediately.
45
50
  DEFAULT_INITIAL_WAIT = 1
51
+ # Followers spread out over this many seconds past the lease's end, so a
52
+ # large fleet doesn't campaign in one instant.
53
+ LEASE_EXPIRY_JITTER = 1.0
54
+ # Keeps a lease read as about to lapse from turning into a busy loop.
55
+ MIN_FOLLOWER_WAIT = 0.05
56
+ # The renewal has to survive a full Redis: a lapsed lease stops every
57
+ # leader-gated consumer, and nothing would renew it until memory came back.
58
+ # Shebang scripts, so by EVAL for the reason Fetcher::OomDrain gives. A server
59
+ # refusing one raises the original OOM, the failure without this.
60
+ OOM_CAMPAIGN = "#{Fetcher::OomDrain::HEADER}#{Lua::SCRIPTS.fetch(:leader_campaign)}".freeze
61
+ OOM_TOKEN = "#{Fetcher::OomDrain::HEADER}return redis.call('INCR', KEYS[1])\n".freeze
46
62
  OPT_OUT_ENV = 'WURK_LEADER' # native opt-out env
47
63
  SIDEKIQ_OPT_OUT_ENV = 'SIDEKIQ_LEADER' # Sidekiq Ent drop-in alias (§6.2/§7.2)
48
64
  THREAD_NAME = 'wurk-leader'
@@ -71,6 +87,8 @@ module Wurk
71
87
  @owner = owner || cluster_identity
72
88
  @held = false
73
89
  @token = nil
90
+ @lease_remaining = nil
91
+ @campaign_failed = false
74
92
  @thread = nil
75
93
  @done = false
76
94
  @mutex = ::Mutex.new
@@ -98,7 +116,7 @@ module Wurk
98
116
  return false unless transition
99
117
 
100
118
  if transition == :gained
101
- @token = redis_call { |c| c.call('INCR', TOKEN_KEY) }
119
+ @token = redis_call { |c| next_token(c) }
102
120
  dispatch_leader_event
103
121
  end
104
122
  true
@@ -191,9 +209,7 @@ module Wurk
191
209
  # if another process currently holds the key. Sets `@held` accordingly
192
210
  # so `leader?` reflects the latest state regardless of branch.
193
211
  def run_set_or_refresh
194
- won = redis_call do |c|
195
- Wurk::Lua::Loader.eval_cached(c, :leader_campaign, keys: [@key], argv: [@owner, @ttl])
196
- end
212
+ won = redis_call { |c| campaign(c) }
197
213
  if won.to_i == 1
198
214
  outcome = @held ? :held : :gained
199
215
  @held = true
@@ -201,9 +217,37 @@ module Wurk
201
217
  end
202
218
 
203
219
  step_down
220
+ read_lease_remaining
204
221
  nil
205
222
  end
206
223
 
224
+ def campaign(conn)
225
+ Wurk::Lua::Loader.eval_cached(conn, :leader_campaign, keys: [@key], argv: [@owner, @ttl])
226
+ rescue RedisClient::OutOfMemoryError => e
227
+ past_maxmemory(e) { conn.call('EVAL', OOM_CAMPAIGN, 1, @key, @owner, @ttl) }
228
+ end
229
+
230
+ def next_token(conn)
231
+ conn.call('INCR', TOKEN_KEY)
232
+ rescue RedisClient::OutOfMemoryError => e
233
+ past_maxmemory(e) { conn.call('EVAL', OOM_TOKEN, 1, TOKEN_KEY) }
234
+ end
235
+
236
+ def past_maxmemory(error)
237
+ yield
238
+ rescue RedisClient::ConnectionError
239
+ raise
240
+ rescue RedisClient::CommandError
241
+ raise error
242
+ end
243
+
244
+ # Seconds until the holder's lease runs out; nil for a lock with no TTL, which
245
+ # only expiry cannot free. A lock gone again already is a zero wait.
246
+ def read_lease_remaining
247
+ pttl = redis_call(idempotent: true) { |c| c.call('PTTL', @key) }
248
+ @lease_remaining = pttl == -1 ? nil : [pttl, 0].max / 1000.0
249
+ end
250
+
207
251
  def step_down
208
252
  @held = false
209
253
  @token = nil
@@ -283,10 +327,14 @@ module Wurk
283
327
  # would leave every leader-gated consumer acting as leader through an
284
328
  # outage long enough for a follower to take over, and would keep this loop
285
329
  # on the short renew cadence. Stepping down makes the next success a fresh
286
- # gain — a new fencing token and a re-fired `:leader` event.
330
+ # gain — a new fencing token and a re-fired `:leader` event. It says nothing
331
+ # about the lease, so the retry is on the renew cadence, not the follower's:
332
+ # a leader that failed one renewal still has time to keep its lease.
287
333
  def tick_once
334
+ @campaign_failed = false
288
335
  acquire
289
336
  rescue StandardError => e
337
+ @campaign_failed = true
290
338
  step_down
291
339
  report(e)
292
340
  end
@@ -296,7 +344,13 @@ module Wurk
296
344
  end
297
345
 
298
346
  def wait_next
299
- wait_for(@held ? @renew_interval : @follower_interval)
347
+ wait_for(@held || @campaign_failed ? @renew_interval : follower_wait)
348
+ end
349
+
350
+ def follower_wait
351
+ return @follower_interval unless @lease_remaining
352
+
353
+ [@lease_remaining + (rand * LEASE_EXPIRY_JITTER), @follower_interval].min.clamp(MIN_FOLLOWER_WAIT, nil)
300
354
  end
301
355
 
302
356
  def wait_for(interval)
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../boot_checks'
4
+ require_relative '../keys'
5
+ require_relative 'payload_sample'
6
+
7
+ module Wurk
8
+ module Preflight
9
+ # The questions a preflight answers, each reading Redis through the Reader
10
+ # and writing to the Report. Run before the cutover, so a live stock fleet is
11
+ # normal here (Verify is the one that expects Wurk).
12
+ class Checks
13
+ MIN_REDIS = ::Gem::Version.new('7.0.0')
14
+ DRAGONFLY_FULL_RATIO = 0.8
15
+
16
+ # Add-ons that are known not to work on Wurk, or that must be gone by cutover.
17
+ KNOWN_BROKEN = {
18
+ 'sidekiq-pro' => 'licensed gem: remove it, Wurk replaces it',
19
+ 'sidekiq-ent' => 'licensed gem: remove it, Wurk replaces it',
20
+ 'sidekiq-status' => 'untested on Wurk, and its web tab expects stock ERB rendering; prefer `track:`',
21
+ 'sidekiq-throttled' => 'patches stock fetch classes, which Wurk does not use; use Sidekiq::Limiter'
22
+ }.freeze
23
+
24
+ def initialize(reader:, report:, keyspace:, owners:, sample:)
25
+ @reader = reader
26
+ @report = report
27
+ @keyspace = keyspace
28
+ @owners = owners
29
+ @sample = sample
30
+ end
31
+
32
+ def run
33
+ server
34
+ processes
35
+ scan_coverage
36
+ private_lists
37
+ payloads
38
+ batches
39
+ locks
40
+ end
41
+
42
+ def app(specs: ::Gem.loaded_specs)
43
+ add_ons(specs)
44
+ ignored_locks
45
+ end
46
+
47
+ private
48
+
49
+ def server
50
+ info = parse_info(@reader.call('INFO'))
51
+ name, version = BootChecks.backend(info)
52
+ @report.section('redis', server_section(info, name, version))
53
+ version_check(name, version)
54
+ topology(info)
55
+ eviction(info['maxmemory_policy'], name)
56
+ dragonfly_memory(info) if name == 'dragonfly'
57
+ end
58
+
59
+ def server_section(info, name, version)
60
+ { 'backend' => name, 'version' => version, 'mode' => info['redis_mode'], 'role' => info['role'],
61
+ 'eviction' => info['maxmemory_policy'], 'used_memory' => info['used_memory'].to_i,
62
+ 'maxmemory' => info['maxmemory'].to_i, 'keys' => @reader.call('DBSIZE') }
63
+ end
64
+
65
+ def parse_info(text)
66
+ text.each_line.with_object({}) do |line, out|
67
+ key, value = line.strip.split(':', 2)
68
+ out[key] = value if value
69
+ end
70
+ end
71
+
72
+ def version_check(name, version)
73
+ return unless name == 'redis' && ::Gem::Version.correct?(version) && ::Gem::Version.new(version) < MIN_REDIS
74
+
75
+ @report.warning('redis_version', "Redis #{version} is below the required 7.0.")
76
+ end
77
+
78
+ def topology(info)
79
+ return unless info['cluster_enabled'] == '1' || info['redis_mode'] == 'cluster'
80
+
81
+ @report.warning('redis_cluster', 'Redis Cluster is not supported: `nodes:` raises. Use standalone or Sentinel.')
82
+ end
83
+
84
+ def eviction(policy, name)
85
+ return if policy == 'noeviction'
86
+
87
+ if policy.nil?
88
+ @report.info('eviction_unknown', "#{name} does not report maxmemory_policy: confirm it cannot evict keys.")
89
+ else
90
+ @report.warning('eviction', "maxmemory_policy is #{policy}: an evicting policy can silently drop queues.")
91
+ end
92
+ end
93
+
94
+ def dragonfly_memory(info)
95
+ used = info['used_memory'].to_f
96
+ max = info['maxmemory'].to_f
97
+ if max.positive? && used / max >= DRAGONFLY_FULL_RATIO
98
+ @report.warning('dragonfly_memory', "Dragonfly is at #{(100 * used / max).round}% of maxmemory. Near the " \
99
+ 'limit it can drop a job mid-move: see docs/deployment.md.')
100
+ else
101
+ @report.info('dragonfly', 'Dragonfly: keep it well below maxmemory and alert on it. See docs/deployment.md.')
102
+ end
103
+ end
104
+
105
+ def processes # rubocop:disable Metrics/AbcSize
106
+ wurk, other = @owners.processes.partition(&:wurk)
107
+ by_version = @owners.processes.group_by { |p| "#{p.wurk ? 'wurk' : 'other'} #{p.version}" }
108
+ @report.section('processes', { 'live' => @owners.processes.size, 'stale_members' => @owners.stale.size,
109
+ 'by_version' => by_version.transform_values(&:size) })
110
+ return unless wurk.any? && other.any?
111
+
112
+ @report.warning('mixed_fleet', "#{other.size} non-Wurk and #{wurk.size} Wurk process(es) share this Redis.")
113
+ end
114
+
115
+ def scan_coverage
116
+ @report.section('keyspace', @keyspace.to_h)
117
+ return unless @keyspace.truncated?
118
+
119
+ @report.warning('scan_truncated', "Stopped at #{@keyspace.scanned} keys: every count below is a lower bound " \
120
+ 'and this run cannot call the keyspace clean. Raise --limit.')
121
+ end
122
+
123
+ def private_lists
124
+ lists = @owners.classify(@keyspace.private_lists)
125
+ examples = lists.transform_values { |keys| keys.first(10) }
126
+ @report.section('private_lists', lists.transform_values(&:size).merge('samples' => examples))
127
+ name_unparseable(lists['unparseable'])
128
+ return if lists['reclaimable'].empty?
129
+
130
+ @report.warning('reclaimable_lists', "#{lists['reclaimable'].size} private list(s) have no live owner. Wurk " \
131
+ 'reclaims these shapes after its grace; stock Sidekiq never does.')
132
+ end
133
+
134
+ def name_unparseable(keys)
135
+ return if keys.empty?
136
+
137
+ @report.warning('unparseable_lists', "#{keys.size} key(s) look like private lists in a shape Wurk cannot " \
138
+ "read, and it will not reclaim them: #{keys.first(10).join(', ')}. " \
139
+ 'Inspect them; if their owners are gone, move the jobs back by hand.')
140
+ end
141
+
142
+ def payloads
143
+ sampled = PayloadSample.new(@reader, per_source: @sample).take(@owners.queue_names)
144
+ @report.section('payloads', sampled.to_h)
145
+ sampled.report_to(@report, foreign: @owners.foreign.any?)
146
+ end
147
+
148
+ def batches # rubocop:disable Metrics/AbcSize
149
+ hashes = batch_hashes
150
+ fields = @reader.pipeline(hashes.map { |key| ['HMGET', key, 'pending', 'callbacks'] })
151
+ in_flight = hashes.zip(fields).select { |_, (pending, _)| pending.to_i.positive? }
152
+ bare = in_flight.reject { |_, (_, callbacks)| callbacks }.map(&:first)
153
+ @report.section('batches', { 'seen' => @keyspace.counts['batches'], 'inspected' => hashes.size,
154
+ 'in_flight' => in_flight.size, 'without_callbacks' => bare.size })
155
+ return if bare.empty?
156
+
157
+ @report.warning('batch_without_callbacks',
158
+ "#{bare.size} in-flight batch(es) have no `callbacks` field, e.g. #{bare.first}. Wurk finds " \
159
+ 'no callbacks to fire on these: let them finish before the cutover.')
160
+ end
161
+
162
+ def batch_hashes
163
+ rows = @keyspace.batches.first(@sample)
164
+ rows.zip(@reader.pipeline(rows.map { |key| ['TYPE', key] })).filter_map { |key, type| key if type == 'hash' }
165
+ end
166
+
167
+ def locks
168
+ counts = @keyspace.counts
169
+ @report.section('locks', counts.slice('unique_sha1', 'unique_sha256', 'unique_other', 'gem_locks', 'gem_keys'))
170
+ if counts['unique_sha1'].positive?
171
+ @report.warning('unique_sha1', "#{counts['unique_sha1']} `unique:` lock(s) carry a SHA1-length digest. " \
172
+ "Wurk's digest is SHA256, so it does not see them: one duplicate per " \
173
+ 'identity until their TTL.')
174
+ end
175
+ return unless counts['gem_locks'].positive?
176
+
177
+ @report.info('gem_locks', "#{counts['gem_locks']} sidekiq-unique-jobs lock(s). Keep the gem and they carry " \
178
+ 'over. Drop it and they stay held: UNLINK `uniquejobs:*` before any rollback.')
179
+ end
180
+
181
+ def add_ons(specs)
182
+ found = KNOWN_BROKEN.select { |name, _| specs.key?(name) }
183
+ real = specs['sidekiq']
184
+ @report.section('app', { 'gems_seen' => specs.size, 'known_broken' => found.keys })
185
+ found.each { |name, why| @report.warning('known_broken_gem', "#{name}: #{why}.") }
186
+ return unless real && !real.full_gem_path.include?('sidekiq-shim')
187
+
188
+ @report.warning('real_sidekiq_gem', "The bundle holds a released sidekiq #{real.version}: it would load next " \
189
+ 'to Wurk. Use the shim gem. See docs/migrate-from-sidekiq.md.')
190
+ end
191
+
192
+ def ignored_locks
193
+ return if defined?(::SidekiqUniqueJobs)
194
+
195
+ classes = BootChecks.locked_job_classes
196
+ return if classes.empty?
197
+
198
+ @report.warning('ignored_lock', "#{classes.size} class(es) set `lock:` with sidekiq-unique-jobs not loaded, " \
199
+ "so duplicates run: #{BootChecks.listing(classes)}.")
200
+ end
201
+ end
202
+ end
203
+ end