gritz-core 0.2.0 → 0.4.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/CHANGELOG.md +13 -0
- data/README.md +4 -0
- data/lib/gritz/channel_registry.rb +42 -0
- data/lib/gritz/cli.rb +31 -5
- data/lib/gritz/client/call_context.rb +60 -0
- data/lib/gritz/client/invocation.rb +84 -0
- data/lib/gritz/client.rb +109 -0
- data/lib/gritz/configuration.rb +23 -9
- data/lib/gritz/context.rb +31 -2
- data/lib/gritz/controller.rb +14 -0
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/dispatcher.rb +9 -3
- data/lib/gritz/errors.rb +3 -1
- data/lib/gritz/metrics/aggregator.rb +119 -0
- data/lib/gritz/metrics/recorder.rb +93 -0
- data/lib/gritz/middleware/exception_mapper.rb +14 -7
- data/lib/gritz/middleware/logging.rb +27 -3
- data/lib/gritz/middleware/metrics.rb +29 -0
- data/lib/gritz/middleware/stack.rb +1 -1
- data/lib/gritz/supervisor/admin_server.rb +121 -0
- data/lib/gritz/supervisor/launcher.rb +381 -0
- data/lib/gritz/supervisor/master.rb +220 -29
- data/lib/gritz/supervisor/status_channel.rb +15 -3
- data/lib/gritz/supervisor/worker_handle.rb +4 -3
- data/lib/gritz/testing/cluster.rb +43 -7
- data/lib/gritz/worker/runner.rb +111 -16
- metadata +30 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Gritz
|
|
8
|
+
module Supervisor
|
|
9
|
+
# Keeps process ownership and probes stable while fresh master interpreters replace each other.
|
|
10
|
+
# @api private
|
|
11
|
+
class Launcher
|
|
12
|
+
Generation = Struct.new(:pid, :channel, :token, :role, :metadata, :snapshot, :deadline,
|
|
13
|
+
:kill_at, :kill_sent, :exit_status, :group_reaped, :identities, keyword_init: true)
|
|
14
|
+
SIGNALS = %w[TERM INT QUIT USR1 USR2 TTIN TTOU HUP CHLD].freeze
|
|
15
|
+
|
|
16
|
+
def self.enable_subreaper!
|
|
17
|
+
return unless RUBY_PLATFORM.include?("linux")
|
|
18
|
+
|
|
19
|
+
require "fiddle"
|
|
20
|
+
function = Fiddle::Function.new(Fiddle.dlopen(nil)["prctl"],
|
|
21
|
+
[Fiddle::TYPE_INT, *Array.new(4, Fiddle::TYPE_LONG)], Fiddle::TYPE_INT)
|
|
22
|
+
raise "Cannot enable child process reaping" unless function.call(36, 1, 0, 0, 0).zero?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(command:, logger:, env: ENV, stdout: $stdout, stderr: $stderr, status_io: nil, startup_timeout: 60)
|
|
26
|
+
@command = command
|
|
27
|
+
@logger = logger
|
|
28
|
+
@env = env
|
|
29
|
+
@stdout = stdout
|
|
30
|
+
@stderr = stderr
|
|
31
|
+
@observer = StatusChannel.new(status_io) if status_io
|
|
32
|
+
@startup_timeout = startup_timeout
|
|
33
|
+
@generations = {}
|
|
34
|
+
@serial = 0
|
|
35
|
+
@metrics = Metrics::Aggregator.new
|
|
36
|
+
@exit_code = 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run
|
|
40
|
+
self.class.enable_subreaper!
|
|
41
|
+
@signals = SignalQueue.new(signals: SIGNALS)
|
|
42
|
+
spawn_generation
|
|
43
|
+
loop do
|
|
44
|
+
@signals.drain.each { |name| handle_signal(name) }
|
|
45
|
+
# Reading a reexec request can add a generation, so iterate a stable snapshot.
|
|
46
|
+
@generations.values.each do |generation| # rubocop:disable Style/HashEachMethods
|
|
47
|
+
read_generation(generation)
|
|
48
|
+
reap_generation(generation)
|
|
49
|
+
end
|
|
50
|
+
update_lifecycle
|
|
51
|
+
@admin&.poll
|
|
52
|
+
@observer&.write(status)
|
|
53
|
+
@observer&.flush
|
|
54
|
+
break if @stopping && @generations.empty?
|
|
55
|
+
|
|
56
|
+
ios = [@signals.io, *@generations.values.reject { |generation| generation.channel.closed? }.map { |generation| generation.channel.io },
|
|
57
|
+
*@admin&.ios].compact
|
|
58
|
+
IO.select(ios, nil, nil, 0.02)
|
|
59
|
+
end
|
|
60
|
+
@exit_code
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
@logger.error("Launcher failed: #{e.message}")
|
|
63
|
+
1
|
|
64
|
+
ensure
|
|
65
|
+
cleanup
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def status
|
|
69
|
+
active = @active&.snapshot || {}
|
|
70
|
+
pools = @generations.values.select { |generation| %i[active retiring].include?(generation.role) || (!@active && generation == @candidate) }
|
|
71
|
+
workers = pools.flat_map do |generation|
|
|
72
|
+
generation.snapshot&.fetch(:workers, [])&.map do |worker|
|
|
73
|
+
worker.merge(master_pid: generation.pid, retiring: generation.role == :retiring || worker[:retiring] == true)
|
|
74
|
+
end || []
|
|
75
|
+
end
|
|
76
|
+
state = if @stopping
|
|
77
|
+
"draining"
|
|
78
|
+
elsif @active
|
|
79
|
+
"running"
|
|
80
|
+
else
|
|
81
|
+
"starting"
|
|
82
|
+
end
|
|
83
|
+
active.merge(owner_pid: Process.pid, pid: (@active || @candidate)&.pid, state: state,
|
|
84
|
+
workers: workers, ready_workers: ready_workers(@active), admin_address: @admin&.address,
|
|
85
|
+
masters: @generations.values.map { |generation| { pid: generation.pid, state: generation.role.to_s } }, reexec: @reexec)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
91
|
+
|
|
92
|
+
def spawn_generation
|
|
93
|
+
parent, child = UNIXSocket.pair
|
|
94
|
+
pid = Process.spawn(@env.to_h.merge("GRITZ_INTERNAL_OWNER_FD" => "3"), *@command,
|
|
95
|
+
3 => child, out: @stdout, err: @stderr, pgroup: true)
|
|
96
|
+
@serial += 1
|
|
97
|
+
@candidate = Generation.new(pid: pid, channel: StatusChannel.new(parent), token: @serial, role: :candidate,
|
|
98
|
+
deadline: monotonic + @startup_timeout, identities: {})
|
|
99
|
+
@generations[pid] = @candidate
|
|
100
|
+
@reexec = { state: "starting", pid: pid } if @active
|
|
101
|
+
rescue StandardError
|
|
102
|
+
parent&.close
|
|
103
|
+
raise
|
|
104
|
+
ensure
|
|
105
|
+
child&.close
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read_generation(generation)
|
|
109
|
+
generation.channel.read.each do |row|
|
|
110
|
+
next unless row[:pid] == generation.pid
|
|
111
|
+
|
|
112
|
+
case row[:type]
|
|
113
|
+
when "configured" then configure_generation(generation, row)
|
|
114
|
+
when "status"
|
|
115
|
+
generation.snapshot = row.except(:type)
|
|
116
|
+
live_pids = row.fetch(:workers, []).map { |worker| worker[:pid] }
|
|
117
|
+
generation.identities.delete_if do |pid, key|
|
|
118
|
+
next false if live_pids.include?(pid)
|
|
119
|
+
|
|
120
|
+
@metrics.forget(key)
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
when "reexec" then request_reexec if generation == @active
|
|
124
|
+
when "restart" then @metrics.record_restart(reason: row[:reason])
|
|
125
|
+
when "metrics"
|
|
126
|
+
previous = generation.identities[row[:worker_pid]]
|
|
127
|
+
key = [generation.token, row[:worker_pid], row[:worker_started_at]].freeze
|
|
128
|
+
@metrics.forget(previous) if previous && previous != key
|
|
129
|
+
generation.identities[row[:worker_pid]] = key
|
|
130
|
+
@metrics.apply(key, row.slice(:seq, :delta))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
rescue ConfigurationError, ArgumentError, SystemCallError => e
|
|
134
|
+
fail_candidate(e.message) if generation == @candidate
|
|
135
|
+
@logger.error("Master #{generation.pid}: #{e.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def configure_generation(generation, metadata)
|
|
139
|
+
return if generation.metadata || @stopping
|
|
140
|
+
|
|
141
|
+
%i[admin_bind bind pid_file].each do |field|
|
|
142
|
+
raise ConfigurationError, "Missing master #{field}" unless metadata[field].is_a?(String)
|
|
143
|
+
end
|
|
144
|
+
%i[workers min_ready_workers].each do |field|
|
|
145
|
+
raise ConfigurationError, "Invalid master #{field}" unless metadata[field].is_a?(Integer) && metadata[field] >= 0
|
|
146
|
+
end
|
|
147
|
+
%i[reexec_timeout drain_delay shutdown_timeout].each do |field|
|
|
148
|
+
value = metadata[field]
|
|
149
|
+
raise ConfigurationError, "Invalid master #{field}" unless value.is_a?(Numeric) && value.finite? && value >= 0
|
|
150
|
+
end
|
|
151
|
+
if @metadata
|
|
152
|
+
%i[admin_bind bind pid_file].each do |field|
|
|
153
|
+
raise ConfigurationError, "USR2 cannot change #{field}" unless metadata[field] == @metadata[field]
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
@metadata = metadata
|
|
157
|
+
@admin = AdminServer.new(bind: metadata[:admin_bind], status: -> { status }, ready: -> { ready? },
|
|
158
|
+
metrics: -> { @metrics.render(workers: status[:workers]) }, logger: @logger)
|
|
159
|
+
lock_pid_file(metadata[:pid_file]) unless metadata[:pid_file].empty?
|
|
160
|
+
end
|
|
161
|
+
generation.metadata = metadata
|
|
162
|
+
generation.deadline = [generation.deadline, monotonic + metadata[:reexec_timeout]].min
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ready_workers(generation)
|
|
166
|
+
return 0 unless generation&.snapshot&.dig(:state) == "running"
|
|
167
|
+
|
|
168
|
+
generation.snapshot.fetch(:workers, []).count do |worker|
|
|
169
|
+
worker[:state] == "ready" && worker[:healthy] != false && worker[:retiring] != true
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def booted_workers(generation)
|
|
174
|
+
return 0 unless generation&.snapshot&.dig(:state) == "running"
|
|
175
|
+
|
|
176
|
+
generation.snapshot.fetch(:workers, []).count { |worker| worker[:state] == "ready" && worker[:retiring] != true }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def ready?
|
|
180
|
+
!@stopping && @active && ready_workers(@active) >= @active.metadata[:min_ready_workers]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def request_reexec
|
|
184
|
+
return if @stopping
|
|
185
|
+
|
|
186
|
+
if @candidate || @generations.values.any? { |generation| generation.role == :retiring }
|
|
187
|
+
@pending_reexec = true
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if @active.metadata[:bind].end_with?(":0")
|
|
192
|
+
@reexec = { state: "failed", error: "USR2 requires a fixed RPC port" }
|
|
193
|
+
@logger.warn(@reexec[:error])
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
spawn_generation
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
@reexec = { state: "failed", error: e.message }
|
|
199
|
+
@logger.error("USR2 failed: #{e.message}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def update_lifecycle
|
|
203
|
+
if @candidate&.role == :candidate
|
|
204
|
+
if @candidate.exit_status
|
|
205
|
+
fail_candidate("replacement master exited before readiness", force: false)
|
|
206
|
+
elsif @candidate.channel.closed?
|
|
207
|
+
fail_candidate("replacement master closed its control channel before readiness", force: false)
|
|
208
|
+
elsif monotonic >= @candidate.deadline
|
|
209
|
+
fail_candidate("replacement master readiness timed out")
|
|
210
|
+
elsif @candidate.metadata && (@active ? ready_workers(@candidate) : booted_workers(@candidate)) >= [@candidate.metadata[:workers], 1].max
|
|
211
|
+
promote_candidate
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
if @active&.exit_status && !@stopping
|
|
215
|
+
begin_shutdown(code: @active.exit_status.success? ? 0 : 1)
|
|
216
|
+
elsif @active&.snapshot&.dig(:state) == "draining" && !@stopping
|
|
217
|
+
begin_shutdown
|
|
218
|
+
end
|
|
219
|
+
if @active&.exit_status&.exited? && !@active.exit_status.success?
|
|
220
|
+
@exit_code = 1
|
|
221
|
+
end
|
|
222
|
+
@generations.each_value do |generation|
|
|
223
|
+
kill_group(generation) if generation.kill_at && monotonic >= generation.kill_at
|
|
224
|
+
next unless generation.group_reaped && generation.channel.closed?
|
|
225
|
+
|
|
226
|
+
generation.identities.each_value { |key| @metrics.forget(key) }
|
|
227
|
+
@generations.delete(generation.pid)
|
|
228
|
+
@candidate = nil if @candidate == generation
|
|
229
|
+
end
|
|
230
|
+
if @pending_reexec && !@stopping && !@candidate && @generations.values.none? { |generation| generation.role == :retiring }
|
|
231
|
+
@pending_reexec = false
|
|
232
|
+
request_reexec
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def promote_candidate
|
|
237
|
+
replacement = @candidate
|
|
238
|
+
write_pid(replacement.pid)
|
|
239
|
+
if @active
|
|
240
|
+
@active.role = :retiring
|
|
241
|
+
terminate(@active)
|
|
242
|
+
@metrics.record_restart(reason: "hot_reexec")
|
|
243
|
+
@reexec = { state: "complete", pid: replacement.pid }
|
|
244
|
+
end
|
|
245
|
+
replacement.role = :active
|
|
246
|
+
@active = replacement
|
|
247
|
+
@candidate = nil
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
fail_candidate(e.message)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def fail_candidate(message, force: true)
|
|
253
|
+
return unless @candidate
|
|
254
|
+
|
|
255
|
+
@candidate.role = :failed
|
|
256
|
+
if force
|
|
257
|
+
kill_group(@candidate)
|
|
258
|
+
else
|
|
259
|
+
@candidate.kill_at = monotonic + 1
|
|
260
|
+
end
|
|
261
|
+
@reexec = { state: "failed", error: message }
|
|
262
|
+
@logger.error("USR2 failed: #{message}")
|
|
263
|
+
unless @active
|
|
264
|
+
@exit_code = 1
|
|
265
|
+
@stopping = true
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def reap_generation(generation)
|
|
270
|
+
reap_exited_group(generation)
|
|
271
|
+
kill_group(generation) if generation.exit_status && !generation.group_reaped
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def reap_exited_group(generation)
|
|
275
|
+
loop do
|
|
276
|
+
result = Process.waitpid2(-generation.pid, Process::WNOHANG)
|
|
277
|
+
break unless result
|
|
278
|
+
|
|
279
|
+
generation.exit_status = result.last if result.first == generation.pid
|
|
280
|
+
end
|
|
281
|
+
rescue Errno::ECHILD
|
|
282
|
+
generation.group_reaped = true
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def handle_signal(name)
|
|
286
|
+
case name
|
|
287
|
+
when "TERM", "INT" then begin_shutdown
|
|
288
|
+
when "QUIT"
|
|
289
|
+
@stopping = true
|
|
290
|
+
@candidate.role = :stopping if @candidate
|
|
291
|
+
@generations.each_value { |generation| kill_group(generation) }
|
|
292
|
+
when "USR2" then request_reexec if @active
|
|
293
|
+
when "HUP"
|
|
294
|
+
@logger.reopen if @logger.respond_to?(:reopen)
|
|
295
|
+
@generations.each_value { |generation| signal_generation(generation, name) if %i[active retiring].include?(generation.role) }
|
|
296
|
+
when "USR1"
|
|
297
|
+
if @active && !@stopping
|
|
298
|
+
if @active.metadata[:workers].zero?
|
|
299
|
+
@logger.warn("USR1 requires worker processes; use USR2 to reload a single-process server")
|
|
300
|
+
else
|
|
301
|
+
signal_generation(@active, name)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
when "TTIN", "TTOU" then signal_generation(@active, name) if @active && !@stopping
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def begin_shutdown(code: 0)
|
|
309
|
+
@exit_code = code if code != 0
|
|
310
|
+
return if @stopping
|
|
311
|
+
|
|
312
|
+
@stopping = true
|
|
313
|
+
@generations.each_value do |generation|
|
|
314
|
+
generation.role = :stopping if generation.role == :candidate
|
|
315
|
+
terminate(generation)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def terminate(generation)
|
|
320
|
+
signal_generation(generation, "TERM")
|
|
321
|
+
metadata = generation.metadata || {}
|
|
322
|
+
generation.kill_at = monotonic + metadata.fetch(:drain_delay, 0) + metadata.fetch(:shutdown_timeout, 1) + 1
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def signal_generation(generation, name)
|
|
326
|
+
Process.kill(name, generation.pid) unless generation.exit_status
|
|
327
|
+
rescue Errno::ESRCH
|
|
328
|
+
nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def kill_group(generation)
|
|
332
|
+
reap_exited_group(generation)
|
|
333
|
+
return if generation.group_reaped || generation.kill_sent
|
|
334
|
+
|
|
335
|
+
Process.kill("KILL", -generation.pid)
|
|
336
|
+
generation.kill_sent = true
|
|
337
|
+
generation.kill_at = nil
|
|
338
|
+
rescue Errno::ESRCH
|
|
339
|
+
generation.kill_sent = true
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def lock_pid_file(path)
|
|
343
|
+
@pid_path = File.expand_path(path)
|
|
344
|
+
FileUtils.mkdir_p(File.dirname(@pid_path))
|
|
345
|
+
@pid_lock = File.open("#{@pid_path}.lock", File::RDWR | File::CREAT, 0o644)
|
|
346
|
+
raise ConfigurationError, "PID file is already owned: #{@pid_path}" unless @pid_lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def write_pid(pid)
|
|
350
|
+
return unless @pid_path
|
|
351
|
+
|
|
352
|
+
Tempfile.create([".gritz-pid", ".tmp"], File.dirname(@pid_path)) do |file|
|
|
353
|
+
file.write("#{pid}\n")
|
|
354
|
+
file.flush
|
|
355
|
+
File.rename(file.path, @pid_path)
|
|
356
|
+
end
|
|
357
|
+
@written_pid = pid
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def cleanup
|
|
361
|
+
@generations.each_value do |generation|
|
|
362
|
+
kill_group(generation)
|
|
363
|
+
generation.channel.close
|
|
364
|
+
begin
|
|
365
|
+
loop { Process.waitpid(-generation.pid) }
|
|
366
|
+
rescue Errno::ECHILD
|
|
367
|
+
nil
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
@generations.clear
|
|
371
|
+
@admin&.close
|
|
372
|
+
@observer&.close
|
|
373
|
+
@signals&.close
|
|
374
|
+
if @written_pid && File.file?(@pid_path) && File.read(@pid_path).strip == @written_pid.to_s
|
|
375
|
+
File.unlink(@pid_path)
|
|
376
|
+
end
|
|
377
|
+
@pid_lock&.close
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|