wurk 1.3.1 → 1.5.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 +4 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- data/lib/wurk/batch/callbacks.rb +82 -12
- data/lib/wurk/batch/death_handler.rb +7 -4
- data/lib/wurk/batch/server_middleware.rb +1 -1
- data/lib/wurk/batch.rb +121 -15
- data/lib/wurk/capsule.rb +45 -16
- data/lib/wurk/cli.rb +48 -14
- data/lib/wurk/client/buffered.rb +200 -43
- data/lib/wurk/client.rb +181 -38
- data/lib/wurk/compat.rb +1 -1
- data/lib/wurk/component.rb +36 -4
- data/lib/wurk/configuration.rb +47 -7
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/cron.rb +94 -37
- data/lib/wurk/deploy.rb +5 -3
- data/lib/wurk/embedded.rb +13 -0
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/reaper.rb +125 -58
- data/lib/wurk/fetcher/reliable.rb +366 -51
- data/lib/wurk/fetcher.rb +6 -0
- data/lib/wurk/heartbeat.rb +24 -12
- data/lib/wurk/history.rb +13 -1
- data/lib/wurk/job_logger.rb +16 -7
- data/lib/wurk/job_set.rb +3 -2
- data/lib/wurk/job_util.rb +44 -24
- data/lib/wurk/launcher.rb +230 -119
- data/lib/wurk/leader.rb +63 -14
- data/lib/wurk/limiter/base.rb +8 -10
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/concurrent.rb +27 -22
- data/lib/wurk/limiter/window.rb +13 -11
- data/lib/wurk/limiter.rb +7 -4
- data/lib/wurk/logger.rb +1 -1
- data/lib/wurk/lua/loader.rb +9 -3
- data/lib/wurk/lua.rb +97 -14
- data/lib/wurk/manager.rb +29 -13
- data/lib/wurk/metrics/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +102 -32
- data/lib/wurk/metrics/queue_rollup.rb +13 -1
- data/lib/wurk/metrics/rollup.rb +13 -1
- data/lib/wurk/metrics/statsd.rb +32 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/interrupt_handler.rb +7 -6
- data/lib/wurk/middleware/poison_pill.rb +93 -31
- data/lib/wurk/middleware.rb +2 -2
- data/lib/wurk/pool_checkout.rb +39 -0
- data/lib/wurk/process_set.rb +10 -5
- data/lib/wurk/processor.rb +83 -9
- data/lib/wurk/profiler.rb +9 -4
- data/lib/wurk/queue.rb +18 -7
- data/lib/wurk/rails_boot.rb +38 -7
- data/lib/wurk/redis_client_adapter.rb +49 -5
- data/lib/wurk/redis_pool.rb +71 -25
- data/lib/wurk/scheduled.rb +30 -2
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +19 -10
- data/lib/wurk/swarm/child_boot.rb +36 -4
- data/lib/wurk/swarm.rb +258 -43
- data/lib/wurk/timer_loop.rb +14 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +11 -7
- data/lib/wurk/web/enterprise.rb +58 -6
- data/lib/wurk/web/extension.rb +1 -1
- data/lib/wurk/web/search.rb +5 -3
- data/lib/wurk.rb +12 -10
- data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
- data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
- data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
- data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
- data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +32 -27
- data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
- data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
- data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
data/lib/wurk/job_logger.rb
CHANGED
|
@@ -41,13 +41,7 @@ module Wurk
|
|
|
41
41
|
# under a per-job log level. ActiveJob-wrapped jobs expose the real
|
|
42
42
|
# class via the "wrapped" key — log that, not the wrapper.
|
|
43
43
|
def prepare(job_hash, &block)
|
|
44
|
-
h =
|
|
45
|
-
jid: job_hash['jid'],
|
|
46
|
-
class: job_hash['wrapped'] || job_hash['class']
|
|
47
|
-
}
|
|
48
|
-
@config[:logged_job_attributes].each do |attr|
|
|
49
|
-
h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
|
|
50
|
-
end
|
|
44
|
+
h = context_hash(job_hash)
|
|
51
45
|
|
|
52
46
|
level = job_hash['log_level']
|
|
53
47
|
Wurk::Context.with(h) do
|
|
@@ -64,5 +58,20 @@ module Wurk
|
|
|
64
58
|
def elapsed(start)
|
|
65
59
|
(::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(3)
|
|
66
60
|
end
|
|
61
|
+
|
|
62
|
+
# The attribute list is a host setting (defaults to %w[bid tags]), so the
|
|
63
|
+
# walk cannot be short-circuited on the default keys: a host that adds
|
|
64
|
+
# `tenant_id` logs jobs that carry neither `bid` nor `tags`.
|
|
65
|
+
def context_hash(job_hash)
|
|
66
|
+
h = {
|
|
67
|
+
jid: job_hash['jid'],
|
|
68
|
+
class: job_hash['wrapped'] || job_hash['class']
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@config[:logged_job_attributes].each do |attr|
|
|
72
|
+
h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
|
|
73
|
+
end
|
|
74
|
+
h
|
|
75
|
+
end
|
|
67
76
|
end
|
|
68
77
|
end
|
data/lib/wurk/job_set.rb
CHANGED
|
@@ -65,8 +65,9 @@ module Wurk
|
|
|
65
65
|
#
|
|
66
66
|
# Spec: docs/target/sidekiq-free.md §19.5.
|
|
67
67
|
class JobSet < SortedSet
|
|
68
|
-
# ZADD
|
|
69
|
-
#
|
|
68
|
+
# Plain ZADD, no NX: re-scheduling an identical payload *moves* it to the
|
|
69
|
+
# new timestamp rather than keeping the first one. Mirrors
|
|
70
|
+
# Sidekiq::JobSet#schedule exactly — the last schedule call wins.
|
|
70
71
|
def schedule(timestamp, message)
|
|
71
72
|
Wurk.redis { |conn| conn.call('ZADD', @name, timestamp.to_f.to_s, Wurk.dump_json(message)) }
|
|
72
73
|
end
|
data/lib/wurk/job_util.rb
CHANGED
|
@@ -140,32 +140,52 @@ module Wurk
|
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
JSON_NATIVE = ->(_val) {}
|
|
144
|
+
|
|
145
|
+
# Dispatch table for the args walk, ported from Sidekiq (job_util.rb:80-111).
|
|
146
|
+
# Keyed by `val.class` under `compare_by_identity`, so every node costs one
|
|
147
|
+
# identity lookup + one call instead of the ladder of `Module#===` checks a
|
|
148
|
+
# `case/when` compiles to — the entry node is always the args Array, which
|
|
149
|
+
# sat behind six failing `===` probes.
|
|
150
|
+
#
|
|
151
|
+
# Identity lookup means a *subclass* of a JSON-native type misses the table
|
|
152
|
+
# and falls to the default lambda, i.e. is reported unsafe: an
|
|
153
|
+
# ActiveSupport::SafeBuffer arg raises where a bare String does not. That is
|
|
154
|
+
# Sidekiq's behavior and the drop-in contract, not an oversight. Hash keys
|
|
155
|
+
# are the one exception — they are checked with `is_a?`, so a String
|
|
156
|
+
# subclass key is accepted, again matching Sidekiq.
|
|
157
|
+
RECURSIVE_JSON_UNSAFE = { # rubocop:disable Style/MutableConstant -- frozen below, after the two setup calls
|
|
158
|
+
Integer => JSON_NATIVE,
|
|
159
|
+
Float => JSON_NATIVE,
|
|
160
|
+
TrueClass => JSON_NATIVE,
|
|
161
|
+
FalseClass => JSON_NATIVE,
|
|
162
|
+
NilClass => JSON_NATIVE,
|
|
163
|
+
String => JSON_NATIVE,
|
|
164
|
+
Array => lambda { |val|
|
|
165
|
+
val.each do |e|
|
|
166
|
+
bad = RECURSIVE_JSON_UNSAFE[e.class].call(e)
|
|
167
|
+
return bad unless bad.nil?
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
},
|
|
171
|
+
Hash => lambda { |val|
|
|
172
|
+
val.each do |k, v|
|
|
173
|
+
return k unless k.is_a?(String)
|
|
174
|
+
|
|
175
|
+
bad = RECURSIVE_JSON_UNSAFE[v.class].call(v)
|
|
176
|
+
return bad unless bad.nil?
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
RECURSIVE_JSON_UNSAFE.default = ->(val) { val }
|
|
182
|
+
RECURSIVE_JSON_UNSAFE.compare_by_identity
|
|
183
|
+
RECURSIVE_JSON_UNSAFE.freeze
|
|
184
|
+
private_constant :JSON_NATIVE, :RECURSIVE_JSON_UNSAFE
|
|
185
|
+
|
|
143
186
|
# Returns the first offending value, or nil when the tree is JSON-native.
|
|
144
187
|
def json_unsafe(obj)
|
|
145
|
-
|
|
146
|
-
when String, Integer, Float, TrueClass, FalseClass, NilClass then nil
|
|
147
|
-
when Array then json_unsafe_array(obj)
|
|
148
|
-
when Hash then json_unsafe_hash(obj)
|
|
149
|
-
else obj
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def json_unsafe_array(arr)
|
|
154
|
-
arr.each do |v|
|
|
155
|
-
bad = json_unsafe(v)
|
|
156
|
-
return bad unless bad.nil?
|
|
157
|
-
end
|
|
158
|
-
nil
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def json_unsafe_hash(hash)
|
|
162
|
-
hash.each do |k, v|
|
|
163
|
-
return k unless k.is_a?(String)
|
|
164
|
-
|
|
165
|
-
bad = json_unsafe(v)
|
|
166
|
-
return bad unless bad.nil?
|
|
167
|
-
end
|
|
168
|
-
nil
|
|
188
|
+
RECURSIVE_JSON_UNSAFE[obj.class].call(obj)
|
|
169
189
|
end
|
|
170
190
|
end
|
|
171
191
|
end
|
data/lib/wurk/launcher.rb
CHANGED
|
@@ -11,8 +11,11 @@ require_relative 'leader'
|
|
|
11
11
|
require_relative 'cron'
|
|
12
12
|
require_relative 'metrics/rollup'
|
|
13
13
|
require_relative 'metrics/queue_rollup'
|
|
14
|
+
require_relative 'metrics/flusher'
|
|
14
15
|
require_relative 'history'
|
|
15
16
|
require_relative 'fetcher/reaper'
|
|
17
|
+
require_relative 'timer_loop'
|
|
18
|
+
require_relative 'shutdown_gate'
|
|
16
19
|
|
|
17
20
|
module Wurk
|
|
18
21
|
# Top-level supervisor inside each worker process. Owns the Manager pool
|
|
@@ -43,41 +46,52 @@ module Wurk
|
|
|
43
46
|
# (Sidekiq's drop-in surface). The single source of truth is Heartbeat.
|
|
44
47
|
BEAT_PAUSE = Heartbeat::BEAT_PAUSE
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
# Bound on how long #stop waits for the boot-time reclaim sweep before
|
|
50
|
+
# moving on — it can still be scanning a large keyspace when a fast
|
|
51
|
+
# shutdown lands right after boot; teardown must not hang on it.
|
|
52
|
+
BOOT_RECLAIM_JOIN_TIMEOUT = 5
|
|
53
|
+
|
|
54
|
+
attr_accessor :managers, :poller, :cron_poller, :metrics_rollup, :queue_rollup, :metrics_flusher, :history
|
|
47
55
|
|
|
48
56
|
def initialize(config, embedded: false)
|
|
49
57
|
@config = config
|
|
50
58
|
@embedded = embedded
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
59
|
+
# @done is "quieted": stop fetching, stay alive, report quiet=true. It
|
|
60
|
+
# deliberately does NOT stop the heartbeat — a quieted process that stopped
|
|
61
|
+
# beating would never publish quiet=true and would expire out of the live
|
|
62
|
+
# set (#236). Only #stop ends the beat, by terminating @beat_timer.
|
|
55
63
|
@done = false
|
|
56
|
-
@
|
|
57
|
-
@
|
|
58
|
-
@
|
|
59
|
-
|
|
60
|
-
@metrics_rollup = build_metrics_rollup
|
|
61
|
-
@queue_rollup = build_queue_rollup
|
|
62
|
-
@history = build_history
|
|
64
|
+
@shutdown_gate = ShutdownGate.new
|
|
65
|
+
@beat_timer = TimerLoop.new(BEAT_PAUSE)
|
|
66
|
+
@managers = build_managers
|
|
67
|
+
build_loops
|
|
63
68
|
@leader = build_leader
|
|
64
69
|
@reaper = build_reaper
|
|
65
|
-
@started_at = nil
|
|
66
|
-
@heartbeat = nil
|
|
67
|
-
@heartbeat_thread = nil
|
|
68
|
-
@boot_reclaim_thread = nil
|
|
69
70
|
@health_server = build_health_server
|
|
71
|
+
reset_thread_state
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
# Boot order matters:
|
|
73
75
|
# 1. freeze! the config so mutations after fork are visible mistakes.
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
76
|
+
# A swarm child finds the slot-independent half already frozen by its
|
|
77
|
+
# parent (Configuration#prepare_for_fork!); what closes here is the
|
|
78
|
+
# capsules, which stayed open for this child's slot and pools.
|
|
79
|
+
# 2. start the managers FIRST. They are the only thing here on the
|
|
80
|
+
# time-to-first-job path (the number bench/vs_sidekiq.rb measures);
|
|
81
|
+
# everything below is periodic background work whose first tick is
|
|
82
|
+
# seconds away, so it has nothing to gain from going ahead of them.
|
|
83
|
+
# 3. spawn the heartbeat thread, so the dashboard sees the process
|
|
84
|
+
# right after it can pick up jobs.
|
|
85
|
+
# 4. start the reaper and the boot-time reclaim sweep — kill-9 recovery,
|
|
86
|
+
# not decoration: a SIGKILLed sibling's in-flight jobs wait on this,
|
|
87
|
+
# so it stays ahead of the periodic loops.
|
|
88
|
+
# 5. start the periodic loops. None of them ticks at zero — TimerLoop#run
|
|
89
|
+
# waits an interval before its first yield, Scheduled::Poller waits
|
|
90
|
+
# INITIAL_WAIT, and Leader waits DEFAULT_INITIAL_WAIT — so starting
|
|
91
|
+
# them last costs a few thread spawns and nothing else. Both leader-
|
|
92
|
+
# gated pollers are also safe to start before leadership settles: a
|
|
93
|
+
# non-leader tick just returns early.
|
|
94
|
+
# 6. start the health probe server LAST so the listener doesn't
|
|
81
95
|
# accept k8s probes until the rest of the launcher is up.
|
|
82
96
|
def run(async_beat: true)
|
|
83
97
|
@started_at = Time.now.to_f
|
|
@@ -86,19 +100,22 @@ module Wurk
|
|
|
86
100
|
# CLI, embedded) runs through here, so none boots with a nil fetcher.
|
|
87
101
|
@config.capsules.each_value(&:prepare!)
|
|
88
102
|
@config.freeze!
|
|
89
|
-
@heartbeat_thread = safe_thread('heartbeat', &method(:start_heartbeat)) if async_beat
|
|
90
|
-
@poller&.start
|
|
91
|
-
@leader&.start
|
|
92
|
-
@cron_poller&.start
|
|
93
|
-
@metrics_rollup&.start
|
|
94
|
-
@queue_rollup&.start
|
|
95
|
-
@history&.start
|
|
96
103
|
@managers.each(&:start)
|
|
104
|
+
@heartbeat_thread = safe_thread('heartbeat', &method(:start_heartbeat)) if async_beat
|
|
97
105
|
@reaper.start
|
|
98
106
|
# Run on a background thread so /ready probe isn't delayed by a large
|
|
99
107
|
# orphan sweep (reaper.reclaim! is atomic, but can scan many entries).
|
|
100
108
|
@boot_reclaim_thread = safe_thread('boot-reclaim', &method(:boot_reclaim))
|
|
109
|
+
start_periodic_components
|
|
101
110
|
@health_server&.start
|
|
111
|
+
rescue StandardError
|
|
112
|
+
# Boot is not atomic: whatever raised (a health-check port already bound,
|
|
113
|
+
# ThreadError at the OS thread limit) leaves the steps before it holding
|
|
114
|
+
# threads, sockets and a leader campaign — and the caller is about to drop
|
|
115
|
+
# its only reference to us, so nothing else can ever release them. Guarded,
|
|
116
|
+
# because the caller must see the boot failure, not a rollback failure.
|
|
117
|
+
teardown_step('boot-rollback') { stop }
|
|
118
|
+
raise
|
|
102
119
|
end
|
|
103
120
|
|
|
104
121
|
# Idempotent. Flips `stopping?` true, halts fetching across every
|
|
@@ -116,28 +133,35 @@ module Wurk
|
|
|
116
133
|
fire_event(:quiet, reverse: true)
|
|
117
134
|
end
|
|
118
135
|
|
|
119
|
-
# Graceful shutdown
|
|
120
|
-
#
|
|
121
|
-
#
|
|
136
|
+
# Graceful shutdown, single-shot: several requests can be in flight at once
|
|
137
|
+
# (a dashboard-queued TERM, the embedded host's own `Embedded#stop`, a
|
|
138
|
+
# Manager that can no longer hold its concurrency), so the first caller
|
|
139
|
+
# drains and the rest wait it out — see ShutdownGate. Deadline is monotonic
|
|
140
|
+
# so wall-clock skew can't extend it. Managers stop in parallel threads so a
|
|
141
|
+
# slow capsule doesn't block its siblings — and each drain is guarded,
|
|
142
|
+
# because a capsule that blows up mid-drain (Redis down during bulk_requeue)
|
|
143
|
+
# used to surface out of `join` and skip the whole teardown tail, leaving the
|
|
144
|
+
# leader lock held, the process listed as live, and its port open.
|
|
145
|
+
#
|
|
146
|
+
# The joins get one shared budget of `deadline + TimerLoop::JOIN_TIMEOUT`,
|
|
147
|
+
# not an open-ended wait. Manager#stop is meant to run a little past the
|
|
148
|
+
# deadline — on expiry it bulk_requeues the in-flight UnitsOfWork, kills the
|
|
149
|
+
# threads and gives them a ~3s window to run their `ensure` blocks — but
|
|
150
|
+
# past that it is wedged (a `fetcher.terminate` / `bulk_requeue` parked on a
|
|
151
|
+
# Redis that stopped answering), and every further second spent waiting
|
|
152
|
+
# comes out of the teardown tail below, which the swarm parent only allows
|
|
153
|
+
# `shutdown_timeout + Swarm::SHUTDOWN_GRACE` for before it SIGKILLs us
|
|
154
|
+
# mid-drain.
|
|
122
155
|
def stop
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
@queue_rollup&.terminate
|
|
133
|
-
@history&.terminate
|
|
134
|
-
@reaper&.stop
|
|
135
|
-
# CAS-release the cluster lock now (planned shutdown) so a follower can
|
|
136
|
-
# take over immediately instead of waiting out the TTL.
|
|
137
|
-
@leader&.stop
|
|
138
|
-
stop_heartbeat
|
|
139
|
-
clear_heartbeat
|
|
140
|
-
fire_event(:exit, reverse: true)
|
|
156
|
+
@shutdown_gate.run do
|
|
157
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + (@config[:timeout] || 25)
|
|
158
|
+
quiet
|
|
159
|
+
stoppers = @managers.map { |m| Thread.new { teardown_step('manager') { m.stop(deadline) } } }
|
|
160
|
+
fire_event(:shutdown, reverse: true)
|
|
161
|
+
@shutdown_gate.join_within(stoppers, deadline + TimerLoop::JOIN_TIMEOUT)
|
|
162
|
+
ensure
|
|
163
|
+
release_components
|
|
164
|
+
end
|
|
141
165
|
end
|
|
142
166
|
|
|
143
167
|
def stopping?
|
|
@@ -162,9 +186,12 @@ module Wurk
|
|
|
162
186
|
|
|
163
187
|
write_stats(processed, failed, expired)
|
|
164
188
|
rescue StandardError => e
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
189
|
+
# The counters were reset above, so a raise here drops this window's stats
|
|
190
|
+
# for good: #write_stats cannot claim apply-safety and the pool no longer
|
|
191
|
+
# replays it through a blip. Accepted deliberately — adding them back
|
|
192
|
+
# would double-count the case where the INCRBYs did land and only the
|
|
193
|
+
# reply was lost, and the per-job at-least-once semantics don't apply to
|
|
194
|
+
# *counters*. The next beat resets again.
|
|
168
195
|
handle_exception(e, { context: 'flush_stats' })
|
|
169
196
|
end
|
|
170
197
|
|
|
@@ -174,6 +201,77 @@ module Wurk
|
|
|
174
201
|
|
|
175
202
|
private
|
|
176
203
|
|
|
204
|
+
# Teardown tail, driven from #stop's ensure. Every release is guarded on
|
|
205
|
+
# its own because they are independent: a Redis blip in one (leader CAS
|
|
206
|
+
# release, heartbeat clear) must not strand the ones after it — the process
|
|
207
|
+
# is exiting either way, so the only thing worse than a failed release is a
|
|
208
|
+
# skipped one. Order matters twice over: full shutdown stops the periodic
|
|
209
|
+
# loops (they survived #quiet) before the cluster lock is CAS-released, so
|
|
210
|
+
# no tick races a follower's promotion, and the release itself happens on a
|
|
211
|
+
# planned shutdown rather than waiting out the lock TTL.
|
|
212
|
+
def release_components
|
|
213
|
+
# Joined first, before anything below tears down the Redis pool it's
|
|
214
|
+
# still reading from: it started as a fire-and-forget thread in #run
|
|
215
|
+
# and a shutdown landing right after boot could otherwise race a
|
|
216
|
+
# pool disconnect mid-scan. Bounded: a full-keyspace scan can outlast
|
|
217
|
+
# the timeout, in which case the thread is left running rather than
|
|
218
|
+
# blocking shutdown on it — #boot_reclaim already rescues and logs on
|
|
219
|
+
# its own, so a straggler racing #reset_redis_pools! below just means
|
|
220
|
+
# a noisy log line, not a hang.
|
|
221
|
+
teardown_step('boot-reclaim-join') { @boot_reclaim_thread&.join(BOOT_RECLAIM_JOIN_TIMEOUT) }
|
|
222
|
+
stop_periodic_components
|
|
223
|
+
%i[stop_heartbeat clear_heartbeat].each { |step| teardown_step(step) { send(step) } }
|
|
224
|
+
# Unguarded: fire_event already reports and skips past a raising hook.
|
|
225
|
+
fire_event(:exit, reverse: true)
|
|
226
|
+
# Unconditional. A swarm child or standalone process exits right after
|
|
227
|
+
# #stop, so for those it only closes sockets the kernel was about to close
|
|
228
|
+
# anyway — but every other driver survives us: an embedded host (Puma, a
|
|
229
|
+
# rake task) that boots a fresh Launcher later, and anything driving
|
|
230
|
+
# Launcher directly. Skipping those leaked one main + one fetch pool per
|
|
231
|
+
# capsule, live, for the life of the host. Idempotent: `reset_redis_pools!`
|
|
232
|
+
# drops the memo, so the next checkout rebuilds.
|
|
233
|
+
teardown_step('redis-pools') { @config.reset_redis_pools! }
|
|
234
|
+
ensure
|
|
235
|
+
# In an ensure of its own, not merely a guarded step: a leaked TCPServer
|
|
236
|
+
# FD survives even a non-StandardError unwind (a second TERM landing
|
|
237
|
+
# mid-teardown), and kubelet would keep getting 200s from a process that
|
|
238
|
+
# is already gone.
|
|
239
|
+
teardown_step('health-server') { @health_server&.stop }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Mirror of #stop_periodic_components: every loop this process runs on a
|
|
243
|
+
# timer, started in one place at the tail of #run. `compact` because the
|
|
244
|
+
# history snapshotter is opt-in and an embedded boot may have stripped the
|
|
245
|
+
# rest.
|
|
246
|
+
def start_periodic_components
|
|
247
|
+
[@poller, @leader, @cron_poller, @metrics_rollup,
|
|
248
|
+
@queue_rollup, @metrics_flusher, @history].compact.each(&:start)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Split out of #release_components to keep it under the AbcSize/
|
|
252
|
+
# CyclomaticComplexity ceilings — these are the "periodic loop" releases,
|
|
253
|
+
# independently guarded like everything else in the tail.
|
|
254
|
+
#
|
|
255
|
+
# The metrics flusher goes first: its #terminate drains the last window of
|
|
256
|
+
# per-job counters into the `j|…` minute buckets, and Metrics::Rollup reads
|
|
257
|
+
# exactly those buckets — stopping the rollup first would give its final
|
|
258
|
+
# tick nothing to roll.
|
|
259
|
+
def stop_periodic_components
|
|
260
|
+
[@metrics_flusher, @cron_poller, @metrics_rollup, @queue_rollup,
|
|
261
|
+
@history].each { |t| teardown_step(t.class) { t&.terminate } }
|
|
262
|
+
teardown_step('reaper') { @reaper&.stop }
|
|
263
|
+
teardown_step('leader') { @leader&.stop }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def teardown_step(label)
|
|
267
|
+
yield
|
|
268
|
+
rescue StandardError => e
|
|
269
|
+
handle_exception(e, { context: "launcher-stop-#{label}" })
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# INCRBY is additive, so a pipeline replayed after a lost reply counts this
|
|
273
|
+
# window twice on every `stat:` key it touches — no apply-safety claim. (The
|
|
274
|
+
# EXPIREs riding along are harmless to repeat; the INCRBYs pin the block.)
|
|
177
275
|
def write_stats(processed, failed, expired)
|
|
178
276
|
day = Time.now.utc.strftime('%F')
|
|
179
277
|
@config.redis do |conn|
|
|
@@ -217,117 +315,130 @@ module Wurk
|
|
|
217
315
|
|
|
218
316
|
# Erase the live-process footprint. flush_stats first so we don't drop
|
|
219
317
|
# the final batch of counters; then Heartbeat#stop! removes us from the
|
|
220
|
-
# `processes` SET and UNLINK-s the identity + work hashes.
|
|
221
|
-
# server is closed alongside so kubelet stops getting 200s after the
|
|
222
|
-
# process is no longer healthy.
|
|
318
|
+
# `processes` SET and UNLINK-s the identity + work hashes.
|
|
223
319
|
def clear_heartbeat
|
|
224
320
|
flush_stats
|
|
225
321
|
@heartbeat&.stop!
|
|
226
|
-
@health_server&.stop
|
|
227
322
|
end
|
|
228
323
|
|
|
229
324
|
# Terminate the heartbeat loop and wait for it to exit before clear_heartbeat
|
|
230
325
|
# removes us from the `processes` SET — otherwise a final in-flight beat could
|
|
231
|
-
# SADD us back right after the SREM
|
|
232
|
-
#
|
|
326
|
+
# SADD us back right after the SREM and the identity would linger for a full
|
|
327
|
+
# 60s TTL.
|
|
328
|
+
#
|
|
329
|
+
# The join is unbounded on purpose. This used to be `wakeup` + `join(BEAT_PAUSE)`,
|
|
330
|
+
# which lost the race whenever the beat was mid-Redis-call: `wakeup` does nothing
|
|
331
|
+
# to a thread that isn't sleeping, so the loop then slept a full interval and the
|
|
332
|
+
# bounded join returned with the thread still live — exactly the resurrect-after-
|
|
333
|
+
# SREM this ordering exists to prevent. A condvar can't be missed (terminate flips
|
|
334
|
+
# the flag inside the critical section the loop re-checks it in), so all that is
|
|
335
|
+
# left to wait out is one in-flight beat, whose Redis calls are timeout-bounded.
|
|
233
336
|
def stop_heartbeat
|
|
234
|
-
@
|
|
337
|
+
@beat_timer.terminate
|
|
235
338
|
thread = @heartbeat_thread
|
|
236
339
|
return unless thread
|
|
237
|
-
# Embedded dashboard-TERM
|
|
238
|
-
# raises ThreadError.
|
|
340
|
+
# Embedded dashboard-TERM can run `stop` from the beat itself; a self-join
|
|
341
|
+
# raises ThreadError. The timer is terminated, so the loop exits after this beat.
|
|
239
342
|
return if thread == Thread.current
|
|
240
343
|
|
|
241
|
-
|
|
242
|
-
thread.wakeup
|
|
243
|
-
rescue ThreadError
|
|
244
|
-
nil
|
|
245
|
-
end
|
|
246
|
-
thread.join(BEAT_PAUSE)
|
|
344
|
+
thread.join
|
|
247
345
|
end
|
|
248
346
|
|
|
249
|
-
# Heartbeat thread loop. `safe_thread` already wraps exceptions.
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
347
|
+
# Heartbeat thread loop. `safe_thread` already wraps exceptions. Beats once up
|
|
348
|
+
# front — TimerLoop#run waits before its first yield, and the dashboard has to
|
|
349
|
+
# see this process the moment it can pick up jobs — then ticks until #stop
|
|
350
|
+
# terminates the timer. Note it does NOT stop on `@done`: a *quieted* process
|
|
351
|
+
# keeps beating so it publishes `quiet=true` instead of vanishing (#236).
|
|
254
352
|
def start_heartbeat
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
sleep BEAT_PAUSE
|
|
258
|
-
end
|
|
353
|
+
heartbeat
|
|
354
|
+
@beat_timer.run { heartbeat }
|
|
259
355
|
logger.info('Heartbeat stopping...')
|
|
260
356
|
end
|
|
261
357
|
|
|
262
358
|
# Dashboard-queued signals must behave exactly like OS signals, so a
|
|
263
|
-
# standalone process re-delivers to itself and lets the installed trap
|
|
264
|
-
#
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
# its own thread because `stop` joins the heartbeat thread we're on.
|
|
359
|
+
# standalone process re-delivers to itself and lets the installed trap run —
|
|
360
|
+
# that wakes the main thread (CLI self-pipe / child dispatcher) so the
|
|
361
|
+
# process actually exits instead of stopping its managers and then parking
|
|
362
|
+
# forever. Embedded mode owns no traps (and self-TERM would kill the host
|
|
363
|
+
# app), so quiet applies directly.
|
|
269
364
|
def dispatch_signal(sig)
|
|
270
365
|
case sig
|
|
271
|
-
when 'TSTP'
|
|
272
|
-
|
|
273
|
-
sig == 'TSTP' ? quiet : Thread.new { stop }
|
|
274
|
-
else
|
|
275
|
-
redeliver(sig)
|
|
276
|
-
end
|
|
366
|
+
when 'TSTP' then @embedded ? quiet : redeliver(sig)
|
|
367
|
+
when 'TERM' then request_shutdown
|
|
277
368
|
else
|
|
278
369
|
logger.warn { "Unknown signal in #{identity}-signals: #{sig.inspect}" }
|
|
279
370
|
end
|
|
280
371
|
end
|
|
281
372
|
|
|
373
|
+
# The one way anything inside this process asks it to shut down gracefully:
|
|
374
|
+
# dashboard-queued TERM, or a Manager that can no longer hold its
|
|
375
|
+
# concurrency. Standalone hands off to the installed TERM trap; embedded
|
|
376
|
+
# owns no traps, so it drains in place — on its own thread, because callers
|
|
377
|
+
# may be a thread `stop` itself joins (the heartbeat) or kills (a Processor).
|
|
378
|
+
#
|
|
379
|
+
# That thread is spawned once and retained by the gate: every later request
|
|
380
|
+
# rides the same drain instead of leaving another unreferenced `stop`
|
|
381
|
+
# running behind it. Returned so a caller that can afford to wait (a test,
|
|
382
|
+
# a host teardown) has something to join.
|
|
383
|
+
def request_shutdown
|
|
384
|
+
return redeliver('TERM') unless @embedded
|
|
385
|
+
|
|
386
|
+
@shutdown_gate.runner { safe_thread('launcher-stop') { stop } }
|
|
387
|
+
end
|
|
388
|
+
|
|
282
389
|
# Separate method so tests can stub it — really sending TERM/TSTP would
|
|
283
390
|
# kill or suspend the test process.
|
|
284
391
|
def redeliver(sig)
|
|
285
392
|
::Process.kill(sig, ::Process.pid)
|
|
286
393
|
end
|
|
287
394
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
# one, but only the elected leader enqueues — the single-leader invariant is
|
|
294
|
-
# what guarantees exactly one enqueue per (loop, tick) across the cluster.
|
|
295
|
-
def build_cron_poller
|
|
296
|
-
Wurk::Cron::Poller.new(@config)
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
# Leader-only metrics rollup. Every process runs one, but only the elected
|
|
300
|
-
# leader writes the cluster-total time-series buckets the dashboard charts
|
|
301
|
-
# read — a non-leader tick returns early. Tune the cadence (tests shrink it)
|
|
302
|
-
# with `config.metrics_rollup_interval`.
|
|
303
|
-
def build_metrics_rollup
|
|
304
|
-
Wurk::Metrics::Rollup.new(@config)
|
|
395
|
+
# One Manager per capsule, each holding our shutdown request: a Manager
|
|
396
|
+
# that can no longer replace a dead Processor has to take the process
|
|
397
|
+
# down, and only the Launcher knows how this process exits.
|
|
398
|
+
def build_managers
|
|
399
|
+
@config.capsules.values.map { |cap| Manager.new(cap, shutdown: method(:request_shutdown)) }
|
|
305
400
|
end
|
|
306
401
|
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
|
|
311
|
-
|
|
402
|
+
# The periodic loops, built in one place because they are started together
|
|
403
|
+
# (#run) and released together (#stop_periodic_components). Every process
|
|
404
|
+
# runs every one of them; what differs is who does the work inside a tick:
|
|
405
|
+
#
|
|
406
|
+
# poller / reaper every process, work shared through Redis
|
|
407
|
+
# cron_poller leader only enqueues — that is the exactly-once guard
|
|
408
|
+
# metrics_rollup leader only writes the cluster-total chart buckets
|
|
409
|
+
# (cadence: `config.metrics_rollup_interval`)
|
|
410
|
+
# queue_rollup leader only writes the `qm|…` per-queue gauges
|
|
411
|
+
# metrics_flusher NOT leader-gated: the counters it drains exist only in
|
|
412
|
+
# this process's memory, so every process flushes its own
|
|
413
|
+
# history Ent §5 snapshotter, leader-gated, opt-in via
|
|
414
|
+
# `config.retain_history`
|
|
415
|
+
def build_loops
|
|
416
|
+
@poller = Wurk::Scheduled::Poller.new(@config)
|
|
417
|
+
@cron_poller = Wurk::Cron::Poller.new(@config)
|
|
418
|
+
@metrics_rollup = Wurk::Metrics::Rollup.new(@config)
|
|
419
|
+
@queue_rollup = Wurk::Metrics::QueueRollup.new(@config)
|
|
420
|
+
@metrics_flusher = Wurk::Metrics::Flusher.new(@config)
|
|
421
|
+
@history = Wurk::History.new(@config) if @config.history_enabled?
|
|
312
422
|
end
|
|
313
423
|
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
Wurk::History.new(@config) if @config.history_enabled?
|
|
424
|
+
# Filled in by #run. Declared up front so #initialize reads as the full
|
|
425
|
+
# inventory of what a Launcher owns.
|
|
426
|
+
def reset_thread_state
|
|
427
|
+
@started_at = @heartbeat = @heartbeat_thread = @boot_reclaim_thread = nil
|
|
319
428
|
end
|
|
320
429
|
|
|
321
430
|
# Every worker process campaigns for the single cluster lock (`dear-leader`);
|
|
322
431
|
# one wins and renews it, the rest follow and promote on its death. Cadence
|
|
323
432
|
# falls back to the spec defaults (TTL 30 / renew 15 / follower 60) unless
|
|
324
|
-
# the host tunes it
|
|
433
|
+
# the host tunes it, plus the short pre-campaign delay that keeps the first
|
|
434
|
+
# CAS off the boot path. `Leader#start` no-ops under `WURK_LEADER=false`.
|
|
325
435
|
def build_leader
|
|
326
436
|
Wurk::Leader.new(
|
|
327
437
|
config: @config,
|
|
328
438
|
ttl: @config[:leader_ttl] || Wurk::Leader::DEFAULT_TTL,
|
|
329
439
|
renew_interval: @config[:leader_renew_interval] || Wurk::Leader::DEFAULT_RENEW_INTERVAL,
|
|
330
|
-
follower_interval: @config[:leader_follower_interval] || Wurk::Leader::DEFAULT_FOLLOWER_INTERVAL
|
|
440
|
+
follower_interval: @config[:leader_follower_interval] || Wurk::Leader::DEFAULT_FOLLOWER_INTERVAL,
|
|
441
|
+
initial_wait: @config[:leader_initial_wait] || Wurk::Leader::DEFAULT_INITIAL_WAIT
|
|
331
442
|
)
|
|
332
443
|
end
|
|
333
444
|
|