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
|
@@ -2,65 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
module Wurk
|
|
4
4
|
class Fetcher
|
|
5
|
-
# The Reaper's
|
|
6
|
-
#
|
|
7
|
-
# a moment before its first heartbeat lands, and draining its list in that
|
|
8
|
-
# window runs the job twice, concurrently.
|
|
5
|
+
# The Reaper's wait between judging a list orphaned by its heartbeat and
|
|
6
|
+
# draining it.
|
|
9
7
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# is
|
|
15
|
-
#
|
|
16
|
-
#
|
|
8
|
+
# A live owner can read as having no heartbeat hash: its first beat failed on
|
|
9
|
+
# a Redis blip, CPU-bound jobs held the beat back, or Redis restarted from an
|
|
10
|
+
# older snapshot. Its next beat lands within the wait, and that spares it.
|
|
11
|
+
#
|
|
12
|
+
# The clock is this process's own and it is shared by nothing else, so it
|
|
13
|
+
# costs the same on every server and at every fleet size. That is the
|
|
14
|
+
# difference from what it replaces, OBJECT IDLETIME: any read of the list
|
|
15
|
+
# (a monitor's LLEN) reset that clock, some servers do not have the command,
|
|
16
|
+
# and a clock kept per sweep only advanced in a process that won the sweep
|
|
17
|
+
# lock twice. The loop thread therefore finishes what any sweep here started,
|
|
18
|
+
# with or without the lock (Reaper#drain_settled).
|
|
17
19
|
class OrphanGrace
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'orphan is drained one sweep later than it would be. Expected under an LFU ' \
|
|
21
|
-
'maxmemory policy or on servers without OBJECT (e.g. Dragonfly).'
|
|
22
|
-
|
|
23
|
-
# Lists remembered for the fallback clock. A list leaves on the pass
|
|
24
|
-
# that clears it; one judged orphaned and then found alive can linger,
|
|
25
|
-
# so the memory is bounded and simply restarts when full — every clock
|
|
26
|
-
# restarting is the conservative direction.
|
|
20
|
+
# Lists remembered at once. Past it the memory restarts, and every clock
|
|
21
|
+
# restarting is the conservative direction: the next sweep judges them anew.
|
|
27
22
|
MEMORY = 10_000
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
Entry = Struct.new(:since, :owner)
|
|
25
|
+
|
|
26
|
+
def initialize(grace)
|
|
31
27
|
@grace = grace
|
|
32
|
-
@
|
|
33
|
-
@
|
|
28
|
+
@lock = ::Mutex.new
|
|
29
|
+
@entries = {}
|
|
34
30
|
end
|
|
35
31
|
|
|
36
|
-
# true while the list must be left alone.
|
|
37
|
-
|
|
32
|
+
# true while the list must be left alone. `owner` is what #take_settled
|
|
33
|
+
# hands back when the wait is over.
|
|
34
|
+
def recent?(key, owner)
|
|
38
35
|
return false unless @grace.positive?
|
|
39
|
-
return local_recent?(key) unless @idletime
|
|
40
36
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
now = monotonic
|
|
38
|
+
@lock.synchronize do
|
|
39
|
+
@entries.clear if @entries.size >= MEMORY && !@entries.key?(key)
|
|
40
|
+
entry = (@entries[key] ||= Entry.new(now, owner))
|
|
41
|
+
next true if now - entry.since < @grace
|
|
42
|
+
|
|
43
|
+
@entries.delete(key)
|
|
44
|
+
false
|
|
45
|
+
end
|
|
47
46
|
end
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
# The owner came back: its next judgement starts a new wait.
|
|
49
|
+
def forget(key)
|
|
50
|
+
@lock.synchronize { @entries.delete(key) }
|
|
51
|
+
end
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
# [[key, owner], ...] whose wait is over, removed from memory.
|
|
54
|
+
def take_settled
|
|
55
|
+
now = monotonic
|
|
56
|
+
@lock.synchronize do
|
|
57
|
+
settled = @entries.select { |_, entry| now - entry.since >= @grace }
|
|
58
|
+
settled.each_key { |key| @entries.delete(key) }
|
|
59
|
+
settled.map { |key, entry| [key, entry.owner] }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Seconds until the next wait ends, nil with nothing waiting.
|
|
64
|
+
def next_due_in
|
|
65
|
+
now = monotonic
|
|
66
|
+
@lock.synchronize do
|
|
67
|
+
oldest = @entries.each_value.map(&:since).min
|
|
68
|
+
oldest && [(oldest + @grace) - now, 0].max
|
|
69
|
+
end
|
|
70
|
+
end
|
|
56
71
|
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
def size
|
|
73
|
+
@lock.synchronize { @entries.size }
|
|
59
74
|
end
|
|
60
75
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def monotonic
|
|
79
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
64
80
|
end
|
|
65
81
|
end
|
|
66
82
|
end
|
|
@@ -38,19 +38,6 @@ module Wurk
|
|
|
38
38
|
nil
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
# `<public_q>|<host>|<pid>|<nonce>|<idx>` → [host, pid, nonce] (pid as
|
|
42
|
-
# Integer), or all-nil when the suffix isn't a well-formed owner tail.
|
|
43
|
-
# Splitting the suffix off the known public-queue prefix tolerates a `|`
|
|
44
|
-
# inside the queue name itself, and leaves the tail unambiguous: exactly
|
|
45
|
-
# 4 segments for the current shape, exactly 3 for the pre-nonce one.
|
|
46
|
-
def parse_owner(public_q, key)
|
|
47
|
-
suffix = key.delete_prefix("#{public_q}|")
|
|
48
|
-
return [nil, nil, nil] if suffix == key
|
|
49
|
-
|
|
50
|
-
host, pid, nonce = owner_tails(suffix.split('|')).first
|
|
51
|
-
[host, pid, nonce]
|
|
52
|
-
end
|
|
53
|
-
|
|
54
41
|
# Owner segments of a private-list key, taken from the right, as
|
|
55
42
|
# [host, pid, nonce, segment_count] readings in preference order (empty
|
|
56
43
|
# when nothing parses). The wide shape is preferred: an all-digit nonce is
|
|
@@ -67,9 +54,7 @@ module Wurk
|
|
|
67
54
|
# The nonce slot must carry PROCESS_NONCE's exact shape (NONCE_PATTERN):
|
|
68
55
|
# on a pre-nonce key read wide it holds the real pid, which is never 12
|
|
69
56
|
# hex, so the guard is what falls such a key through to narrow_tail
|
|
70
|
-
# instead of misreading it.
|
|
71
|
-
# because parse_owner reads through owner_tails too — one guard, and the
|
|
72
|
-
# two parsers agree on every input.
|
|
57
|
+
# instead of misreading it.
|
|
73
58
|
def wide_tail(parts)
|
|
74
59
|
[parts[-4], parts[-3].to_i, parts[-2], 4] if parts.size >= 4 && integer?(parts[-3]) && nonce?(parts[-2])
|
|
75
60
|
end
|
data/lib/wurk/fetcher/reaper.rb
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
require_relative '../component'
|
|
4
4
|
require_relative '../keys'
|
|
5
5
|
require_relative '../middleware/poison_pill'
|
|
6
|
+
require_relative '../metrics/process_counters'
|
|
6
7
|
require_relative '../timer_loop'
|
|
7
8
|
require_relative 'private_list_key'
|
|
8
9
|
require_relative 'unparseable_keys'
|
|
9
10
|
require_relative 'orphan_grace'
|
|
11
|
+
require_relative 'oom_drain'
|
|
12
|
+
require_relative 'self_reconcile'
|
|
10
13
|
|
|
11
14
|
module Wurk
|
|
12
15
|
class Fetcher
|
|
@@ -31,87 +34,94 @@ module Wurk
|
|
|
31
34
|
# supervisor respawns with a fresh pid, so it does not arise in practice.)
|
|
32
35
|
# * any other incarnation — its pid means nothing in our namespace, so we
|
|
33
36
|
# trust the heartbeat: the owner is alive iff its identity's `info`
|
|
34
|
-
# hash still exists. Such reclaim waits out the 60s heartbeat TTL
|
|
37
|
+
# hash still exists. Such reclaim waits out the 60s heartbeat TTL, the
|
|
38
|
+
# next sweep and then the grace: 80-150s after the owner died.
|
|
35
39
|
#
|
|
36
|
-
#
|
|
37
|
-
# a long time on a big keyspace,
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# judged orphan
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# hash. Our own incarnation skips both: kill(0) is not a snapshot.
|
|
40
|
+
# A missing hash is not proof of death on its own. The verdict is a snapshot
|
|
41
|
+
# taken before a SCAN that can run for a long time on a big keyspace, a beat
|
|
42
|
+
# can be held back by CPU-bound jobs, and a Redis restarted from an older
|
|
43
|
+
# snapshot brings back the `processes` SET with every hash lapsed. So a
|
|
44
|
+
# heartbeat-judged orphan must stay orphaned for `grace` seconds on this
|
|
45
|
+
# process's own clock (OrphanGrace), and its identity must still have no
|
|
46
|
+
# `info` hash. Our own incarnation skips both: kill(0) is not a snapshot.
|
|
44
47
|
#
|
|
45
48
|
# Re-pushed jobs run through Wurk::Middleware::PoisonPill, which caps a
|
|
46
49
|
# job at RECOVERY_THRESHOLD recoveries within 72h: past the cap the job
|
|
47
50
|
# is killed into the dead set instead of re-queued, so a job that crashes
|
|
48
51
|
# its worker every time can't loop forever.
|
|
49
52
|
#
|
|
50
|
-
#
|
|
53
|
+
# One sweep an interval: one SCAN of `queue:*|*` acting on every list, gated
|
|
54
|
+
# by a cluster lock so about one process sweeps per interval. Every list, and
|
|
55
|
+
# not only those this process serves: the lock is shared across the fleet, so
|
|
56
|
+
# a scoped sweep would wait for its turn to land on one of the processes
|
|
57
|
+
# serving the queue. The list's own queue is where its jobs go back to,
|
|
58
|
+
# whichever process moves them.
|
|
51
59
|
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# * a *full* sweep at most once an hour ("full SCAN 1/hr"): SCANs the whole
|
|
56
|
-
# `queue:*|*` keyspace, gated by its own hourly lock, so private lists
|
|
57
|
-
# whose public queue no live process serves — a renamed/decommissioned
|
|
58
|
-
# queue, or a dead host's queue no survivor consumes — are recovered too,
|
|
59
|
-
# not stranded forever.
|
|
60
|
+
# Every tick also runs SelfReconcile over this process's own lists, which is
|
|
61
|
+
# the one case liveness cannot answer: the owner is alive and has lost track
|
|
62
|
+
# of a job.
|
|
60
63
|
#
|
|
61
64
|
# Spec: docs/target/sidekiq-pro.md §3.2.
|
|
62
|
-
class Reaper
|
|
65
|
+
class Reaper # rubocop:disable Metrics/ClassLength
|
|
63
66
|
include Component
|
|
64
67
|
|
|
65
|
-
# Sweep cadence in seconds
|
|
66
|
-
#
|
|
67
|
-
# floor below which cross-host orphans can't be detected anyway.
|
|
68
|
+
# Sweep cadence in seconds. 60s matches the heartbeat TTL — the floor below
|
|
69
|
+
# which cross-host orphans can't be detected anyway.
|
|
68
70
|
DEFAULT_INTERVAL = 60
|
|
69
71
|
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
72
|
+
# The lock lapses before the winner's next tick, or a sole survivor would
|
|
73
|
+
# still find its own lock there and sweep every second interval.
|
|
74
|
+
LOCK_TTL_RATIO = 0.8
|
|
73
75
|
|
|
74
|
-
# Seconds a heartbeat-judged private list
|
|
75
|
-
# drained.
|
|
76
|
-
#
|
|
77
|
-
#
|
|
76
|
+
# Seconds a heartbeat-judged private list stays orphaned before it is
|
|
77
|
+
# drained. A beat is due every 10s, and 25 CPU-bound jobs were measured
|
|
78
|
+
# holding one back 17.4s: at a 15s grace one trial in nine drained all 25
|
|
79
|
+
# live jobs. The price is paid only by a host lost for good.
|
|
78
80
|
DEFAULT_GRACE = 30
|
|
79
81
|
|
|
80
82
|
LOCK_KEY = 'super_fetch:reaper'
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
# holds Redis for more than a fraction of a millisecond.
|
|
89
|
-
FULL_SCAN_COUNT = 1000
|
|
83
|
+
|
|
84
|
+
# A sweep walks the whole keyspace, where almost nothing matches: COUNT is
|
|
85
|
+
# the work per SCAN call, so a bigger batch is the same server work in a
|
|
86
|
+
# tenth of the round trips — a million keys is 1,000 calls instead of
|
|
87
|
+
# 10,000. Still small enough that one call never holds Redis for more than
|
|
88
|
+
# a fraction of a millisecond.
|
|
89
|
+
SCAN_COUNT = 1000
|
|
90
90
|
THREAD_NAME = 'wurk-reaper'
|
|
91
91
|
|
|
92
92
|
attr_reader :interval
|
|
93
93
|
|
|
94
|
-
def initialize(config, interval: DEFAULT_INTERVAL, lock_key: LOCK_KEY,
|
|
95
|
-
|
|
94
|
+
def initialize(config, interval: DEFAULT_INTERVAL, lock_key: LOCK_KEY, grace: DEFAULT_GRACE,
|
|
95
|
+
reconcile_confirm: SelfReconcile::DEFAULT_CONFIRM)
|
|
96
96
|
@config = config
|
|
97
97
|
@interval = interval
|
|
98
|
-
@grace = OrphanGrace.new(
|
|
98
|
+
@grace = OrphanGrace.new(grace)
|
|
99
99
|
@lock_key = lock_key
|
|
100
|
-
@full_interval = full_interval
|
|
101
|
-
@full_lock_key = full_lock_key
|
|
102
100
|
@thread = nil
|
|
103
101
|
@done = false
|
|
104
102
|
@mutex = ::Mutex.new
|
|
105
103
|
@sleeper = ::ConditionVariable.new
|
|
106
104
|
@unparseable = UnparseableKeys.new(config)
|
|
105
|
+
@next_sweep_at = 0.0
|
|
106
|
+
@self_reconcile = SelfReconcile.new(config, confirm: reconcile_confirm, stop: -> { done? },
|
|
107
|
+
on_moved: method(:recovered))
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
# Spawns the sweep loop. Idempotent. The loop waits one interval before
|
|
110
111
|
# its first sweep so booting processes don't dogpile Redis and so an
|
|
111
112
|
# un-stopped launcher in a unit test never touches the keyspace.
|
|
113
|
+
#
|
|
114
|
+
# A LIVE loop is the one and only sweep loop and is handed back as-is. A
|
|
115
|
+
# dead one is not: a stop whose bounded join timed out leaves its
|
|
116
|
+
# straggler referenced, and once that straggler has wound down the
|
|
117
|
+
# reference is a thread that will never sweep again — handing it back
|
|
118
|
+
# would leave the reaper dead for good, silently, since `start` looked
|
|
119
|
+
# like it had worked. A fresh loop takes its place; while the straggler
|
|
120
|
+
# is still alive it keeps the sweep to itself, so a restart still cannot
|
|
121
|
+
# run two loops at once.
|
|
112
122
|
def start
|
|
113
123
|
@mutex.synchronize do
|
|
114
|
-
return @thread if @thread
|
|
124
|
+
return @thread if @thread&.alive?
|
|
115
125
|
|
|
116
126
|
@done = false
|
|
117
127
|
@thread = spawn_loop_thread
|
|
@@ -120,7 +130,7 @@ module Wurk
|
|
|
120
130
|
end
|
|
121
131
|
|
|
122
132
|
# Bounded at TimerLoop::JOIN_TIMEOUT like every other periodic component.
|
|
123
|
-
#
|
|
133
|
+
# A sweep is exactly the tick that outlasts a stop: it
|
|
124
134
|
# SCANs the whole `queue:*|*` keyspace and drains what it finds, so on a
|
|
125
135
|
# large or slow Redis it can still be running when shutdown lands. Waiting
|
|
126
136
|
# it out held the entire process's teardown open past the swarm parent's
|
|
@@ -144,18 +154,15 @@ module Wurk
|
|
|
144
154
|
!@thread.nil? && @thread.alive?
|
|
145
155
|
end
|
|
146
156
|
|
|
147
|
-
# One loop tick:
|
|
148
|
-
#
|
|
149
|
-
# Returns the total jobs reclaimed across both.
|
|
157
|
+
# One loop tick: a sweep when this process wins the per-interval lock.
|
|
158
|
+
# Returns the jobs reclaimed.
|
|
150
159
|
def reap
|
|
151
|
-
|
|
152
|
-
reclaimed += reclaim_full! if acquire_full_lock?
|
|
153
|
-
reclaimed
|
|
160
|
+
acquire_lock? ? reclaim! : 0
|
|
154
161
|
end
|
|
155
162
|
|
|
156
|
-
# One unguarded sweep over every
|
|
157
|
-
#
|
|
158
|
-
#
|
|
163
|
+
# One unguarded sweep over every private list. Returns the number of jobs
|
|
164
|
+
# reclaimed (re-queued or killed). Public so boot paths and tests can drive
|
|
165
|
+
# a deterministic pass without the cluster lock.
|
|
159
166
|
#
|
|
160
167
|
# Honours #stop at each boundary: a stop bounding its join leaves the
|
|
161
168
|
# sweep a straggler by design, and the straggler finishes the list it is
|
|
@@ -163,90 +170,65 @@ module Wurk
|
|
|
163
170
|
# otherwise it keeps requeueing orphans and holding the sweep locks
|
|
164
171
|
# past the stop, into whatever runs next.
|
|
165
172
|
def reclaim!
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
served_queues.each do |public_q|
|
|
169
|
-
break if done?
|
|
173
|
+
sweep.tap { wake_for_settling }
|
|
174
|
+
end
|
|
170
175
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
# #reclaim! for a booting process. Deliberately under no lock, not even a
|
|
177
|
+
# short one: a replacement's sweep is what recovers the sibling it replaces,
|
|
178
|
+
# and the sibling may have died a moment after another process booted (a
|
|
179
|
+
# 2s lock turned a child killed right after boot from 1s into 61s). One
|
|
180
|
+
# pass per process costs keyspace / SCAN_COUNT calls.
|
|
181
|
+
def boot_reclaim!
|
|
182
|
+
reclaim!
|
|
174
183
|
end
|
|
175
184
|
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
def
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
each_full_private_list do |key, public_q, host, pid, nonce|
|
|
184
|
-
break if done?
|
|
185
|
-
next unless orphaned?(key, host, pid, nonce, owners)
|
|
185
|
+
# Lists whose grace has run out, whichever sweep started it. Runs on every
|
|
186
|
+
# tick with no lock: the sweep lock rotates between processes, and waiting
|
|
187
|
+
# to win it again is what made reclaim slower as a fleet grew. Two processes
|
|
188
|
+
# draining one list is safe, each move takes one job once.
|
|
189
|
+
def drain_settled
|
|
190
|
+
@grace.take_settled.sum do |key, (public_q, host, pid, nonce)|
|
|
191
|
+
next 0 if done?
|
|
186
192
|
|
|
187
|
-
|
|
193
|
+
owner_gone?(host, pid, nonce) ? drain(key, public_q) : 0
|
|
188
194
|
end
|
|
189
|
-
reclaimed
|
|
190
195
|
end
|
|
191
196
|
|
|
192
197
|
private
|
|
193
198
|
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
@config.capsules.each_value
|
|
199
|
-
.flat_map(&:queues)
|
|
200
|
-
.uniq
|
|
201
|
-
.map { |name| Keys.queue(name) }
|
|
199
|
+
# A sweep off the loop thread (the boot sweep) may have started a grace the
|
|
200
|
+
# loop's current wait knows nothing about.
|
|
201
|
+
def wake_for_settling
|
|
202
|
+
@mutex.synchronize { @sleeper.signal }
|
|
202
203
|
end
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
205
|
+
def sweep
|
|
206
|
+
owners = live_owners
|
|
206
207
|
reclaimed = 0
|
|
207
|
-
each_private_list
|
|
208
|
+
each_private_list do |key, owner|
|
|
208
209
|
break if done?
|
|
209
|
-
next unless orphaned?(key,
|
|
210
|
+
next unless orphaned?(key, owner, owners)
|
|
210
211
|
|
|
211
|
-
reclaimed += drain(key,
|
|
212
|
+
reclaimed += drain(key, owner.first)
|
|
212
213
|
end
|
|
213
214
|
reclaimed
|
|
214
215
|
end
|
|
215
216
|
|
|
216
|
-
# Yields
|
|
217
|
-
#
|
|
218
|
-
# lists (public queue keys carry no `|`).
|
|
219
|
-
def each_private_list(public_q)
|
|
220
|
-
cursor = '0'
|
|
221
|
-
loop do
|
|
222
|
-
break if done?
|
|
223
|
-
|
|
224
|
-
cursor, keys = redis(idempotent: true) do |c|
|
|
225
|
-
c.call('SCAN', cursor, 'MATCH', "#{public_q}|*", 'COUNT', SCAN_COUNT)
|
|
226
|
-
end
|
|
227
|
-
keys.each do |key|
|
|
228
|
-
host, pid, nonce = PrivateListKey.parse_owner(public_q, key)
|
|
229
|
-
pid ? yield(key, host, pid, nonce) : @unparseable.report(key)
|
|
230
|
-
end
|
|
231
|
-
break if cursor == '0'
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Yields [private_list_key, public_q, host, pid, nonce] for every list in
|
|
236
|
-
# the keyspace. MATCH `queue:*|*` matches only private lists (public queue
|
|
217
|
+
# Yields private_list_key, [public_q, host, pid, nonce] for every list.
|
|
218
|
+
# MATCH `queue:*|*` matches only private lists (public queue
|
|
237
219
|
# keys carry no `|`); PrivateListKey.parse_full drops anything that isn't a
|
|
238
220
|
# well-formed `queue:<public>|<host>|<pid>|<nonce>|<idx>`.
|
|
239
|
-
def
|
|
221
|
+
def each_private_list
|
|
240
222
|
cursor = '0'
|
|
241
223
|
loop do
|
|
242
224
|
break if done?
|
|
243
225
|
|
|
244
226
|
cursor, keys = redis(idempotent: true) do |c|
|
|
245
|
-
c.call('SCAN', cursor, 'MATCH', "#{Keys::QUEUE_PREFIX}*|*", 'COUNT',
|
|
227
|
+
c.call('SCAN', cursor, 'MATCH', "#{Keys::QUEUE_PREFIX}*|*", 'COUNT', SCAN_COUNT)
|
|
246
228
|
end
|
|
247
229
|
keys.each do |key|
|
|
248
230
|
parsed = PrivateListKey.parse_full(key)
|
|
249
|
-
parsed ? yield(key,
|
|
231
|
+
parsed ? yield(key, parsed) : @unparseable.report(key)
|
|
250
232
|
end
|
|
251
233
|
break if cursor == '0'
|
|
252
234
|
end
|
|
@@ -271,11 +253,17 @@ module Wurk
|
|
|
271
253
|
# proves it alive, and a nonce-bearing identity is alive on its hash
|
|
272
254
|
# alone even once it has been pruned from the SET. A pre-nonce key can
|
|
273
255
|
# only be matched on the `<host>:<pid>` prefix of that identity.
|
|
274
|
-
def orphaned?(key,
|
|
256
|
+
def orphaned?(key, owner, owners)
|
|
257
|
+
_public_q, host, pid, nonce = owner
|
|
275
258
|
return !local_pid_alive?(pid) if nonce == process_nonce && host == hostname
|
|
276
|
-
return false if owners.include?(owner_key(host, pid, nonce))
|
|
277
259
|
|
|
278
|
-
|
|
260
|
+
if owners.include?(owner_key(host, pid, nonce))
|
|
261
|
+
@grace.forget(key)
|
|
262
|
+
return false
|
|
263
|
+
end
|
|
264
|
+
return false if @grace.recent?(key, owner)
|
|
265
|
+
|
|
266
|
+
owner_gone?(host, pid, nonce)
|
|
279
267
|
end
|
|
280
268
|
|
|
281
269
|
def owner_key(host, pid, nonce)
|
|
@@ -283,11 +271,10 @@ module Wurk
|
|
|
283
271
|
end
|
|
284
272
|
|
|
285
273
|
# The re-check right before a heartbeat-judged drain (see the class doc):
|
|
286
|
-
# the `owners` snapshot may predate the owner's
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
def
|
|
290
|
-
return false if @grace.recent?(key)
|
|
274
|
+
# the `owners` snapshot may predate the owner's beat, or the whole SCAN.
|
|
275
|
+
# Only reached for lists the snapshot already called orphaned, so its round
|
|
276
|
+
# trips are paid per orphan, never per live list.
|
|
277
|
+
def owner_gone?(host, pid, nonce)
|
|
291
278
|
return !live_owners.include?(owner_key(host, pid, nil)) unless nonce
|
|
292
279
|
|
|
293
280
|
redis(idempotent: true) { |c| c.call('HGET', owner_key(host, pid, nonce), 'info') }.nil?
|
|
@@ -341,10 +328,11 @@ module Wurk
|
|
|
341
328
|
queue_name = public_q.delete_prefix(Keys::QUEUE_PREFIX)
|
|
342
329
|
count = 0
|
|
343
330
|
loop do
|
|
344
|
-
job = redis { |c| c
|
|
331
|
+
job = redis { |c| reclaim_one(c, private_list, public_q) }
|
|
345
332
|
break unless job
|
|
346
333
|
|
|
347
334
|
count += 1
|
|
335
|
+
Metrics::ProcessCounters.increment(:reaper_reclaimed)
|
|
348
336
|
poison_off(public_q, job, queue_name)
|
|
349
337
|
end
|
|
350
338
|
count
|
|
@@ -353,20 +341,42 @@ module Wurk
|
|
|
353
341
|
count
|
|
354
342
|
end
|
|
355
343
|
|
|
344
|
+
# A full Redis must not hold a dead worker's jobs back from the workers
|
|
345
|
+
# still able to drain the queue.
|
|
346
|
+
def reclaim_one(conn, private_list, public_q)
|
|
347
|
+
Wurk::Lua::Loader.eval_cached(conn, :reliable_reclaim, keys: [private_list, public_q], argv: [])
|
|
348
|
+
rescue RedisClient::OutOfMemoryError => e
|
|
349
|
+
OomDrain.reclaim(conn, e, private_list, public_q)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# SelfReconcile put a payload back: the same accounting as a reclaim, so a
|
|
353
|
+
# job that kills its processor thread every time still hits the poison cap.
|
|
354
|
+
def recovered(public_q, job)
|
|
355
|
+
Metrics::ProcessCounters.increment(:reaper_reclaimed)
|
|
356
|
+
poison_off(public_q, job, public_q.delete_prefix(Keys::QUEUE_PREFIX))
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# The job is back on its queue whatever happens here. Past maxmemory the
|
|
360
|
+
# count can't be taken (nor the job killed, leaving it where it is): raising
|
|
361
|
+
# would end the drain at one job per pass, with the rest of the list waiting
|
|
362
|
+
# on memory they would help free.
|
|
356
363
|
def poison_off(public_q, job, queue_name)
|
|
357
364
|
return unless Middleware::PoisonPill.track!(job, queue: queue_name, config: @config) == :poison
|
|
358
365
|
|
|
359
366
|
# track! already ZADDed the payload to the dead set; pull the copy we
|
|
360
|
-
# just
|
|
367
|
+
# just moved onto the public tail so it isn't also re-run.
|
|
361
368
|
redis { |c| c.call('LREM', public_q, -1, job) }
|
|
369
|
+
rescue RedisClient::OutOfMemoryError => e
|
|
370
|
+
report(e)
|
|
362
371
|
end
|
|
363
372
|
|
|
364
373
|
def acquire_lock?
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
374
|
+
ttl = (@interval * LOCK_TTL_RATIO * 1000).ceil
|
|
375
|
+
redis do |c|
|
|
376
|
+
c.call('SET', @lock_key, '1', 'NX', 'PX', ttl)
|
|
377
|
+
rescue RedisClient::OutOfMemoryError => e
|
|
378
|
+
OomDrain.lock(c, e, @lock_key, ttl)
|
|
379
|
+
end == 'OK'
|
|
370
380
|
end
|
|
371
381
|
|
|
372
382
|
def spawn_loop_thread
|
|
@@ -377,6 +387,7 @@ module Wurk
|
|
|
377
387
|
end
|
|
378
388
|
|
|
379
389
|
def run_loop
|
|
390
|
+
@next_sweep_at = monotonic + @interval
|
|
380
391
|
until done?
|
|
381
392
|
wait_next
|
|
382
393
|
break if done?
|
|
@@ -385,14 +396,45 @@ module Wurk
|
|
|
385
396
|
end
|
|
386
397
|
end
|
|
387
398
|
|
|
399
|
+
# Each step guarded on its own: a raise in the sweep (lock contention on a
|
|
400
|
+
# blip, a poison callback) must not cost this process its own reconcile.
|
|
401
|
+
# The wait is cut short for a grace running out (#wait_next), and that
|
|
402
|
+
# wake-up is not a sweep's turn.
|
|
388
403
|
def tick_once
|
|
404
|
+
guarded { reap_when_due }
|
|
405
|
+
guarded { drain_settled }
|
|
406
|
+
guarded { @self_reconcile.sweep }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def reap_when_due
|
|
410
|
+
now = monotonic
|
|
411
|
+
return if now < @next_sweep_at
|
|
412
|
+
|
|
413
|
+
@next_sweep_at = now + @interval
|
|
389
414
|
reap
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def guarded
|
|
418
|
+
yield
|
|
390
419
|
rescue StandardError => e
|
|
391
|
-
|
|
420
|
+
report(e)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# The reporter is host code and may raise too (a closed log stream); that
|
|
424
|
+
# must not end the only thread that recovers orphans.
|
|
425
|
+
def report(error)
|
|
426
|
+
handle_exception(error, context: THREAD_NAME) if @config.respond_to?(:handle_exception)
|
|
427
|
+
rescue StandardError => e
|
|
428
|
+
warn "wurk-reaper: error reporter raised #{e.class}: #{e.message}"
|
|
392
429
|
end
|
|
393
430
|
|
|
394
431
|
def wait_next
|
|
395
|
-
|
|
432
|
+
wait = [@next_sweep_at - monotonic, @grace.next_due_in].compact.min
|
|
433
|
+
@mutex.synchronize { @sleeper.wait(@mutex, [wait, 0].max) unless @done }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def monotonic
|
|
437
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
396
438
|
end
|
|
397
439
|
|
|
398
440
|
def done?
|