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.
- checksums.yaml +4 -4
- data/README.md +9 -9
- data/app/controllers/wurk/dashboard_controller.rb +11 -6
- data/exe/wurk +7 -2
- data/exe/wurkswarm +1 -2
- data/lib/generators/wurk/install/install_generator.rb +12 -0
- data/lib/sidekiq/web.rb +5 -0
- data/lib/wurk/boot_checks.rb +8 -2
- data/lib/wurk/cli.rb +140 -31
- data/lib/wurk/client.rb +16 -5
- data/lib/wurk/compat.rb +4 -0
- data/lib/wurk/cron.rb +96 -16
- data/lib/wurk/embedded.rb +8 -2
- data/lib/wurk/encryption.rb +20 -5
- data/lib/wurk/leader.rb +62 -8
- data/lib/wurk/preflight/checks.rb +203 -0
- data/lib/wurk/preflight/command.rb +135 -0
- data/lib/wurk/preflight/keyspace.rb +109 -0
- data/lib/wurk/preflight/owners.rb +74 -0
- data/lib/wurk/preflight/payload_sample.rb +108 -0
- data/lib/wurk/preflight/reader.rb +60 -0
- data/lib/wurk/preflight/report.rb +82 -0
- data/lib/wurk/preflight/verify.rb +161 -0
- data/lib/wurk/preflight.rb +40 -0
- data/lib/wurk/swarm/child_boot.rb +2 -0
- data/lib/wurk/swarm/test_mode_guard.rb +32 -0
- data/lib/wurk/testing.rb +12 -4
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +8 -4
- data/lib/wurk/web/extension.rb +35 -61
- data/lib/wurk/web/helpers.rb +133 -0
- data/lib/wurk/web/paginator.rb +49 -0
- data/lib/wurk/web/rack_app.rb +30 -6
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +13 -1
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
|
|
615
|
-
#
|
|
616
|
-
#
|
|
617
|
-
#
|
|
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
|
-
@
|
|
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
|
-
#
|
|
632
|
-
#
|
|
633
|
-
#
|
|
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
|
-
@
|
|
638
|
-
|
|
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
|
|
648
|
-
#
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/wurk/encryption.rb
CHANGED
|
@@ -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
|
|
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
|
|
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?
|
|
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,
|
|
25
|
-
#
|
|
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
|
|
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
|
|
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 :
|
|
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
|